DEV Community

Cover image for Context vs Zustand vs Redux: A Senior Engineer's Story
Shri Shah
Shri Shah

Posted on

Context vs Zustand vs Redux: A Senior Engineer's Story

What I learned building and scaling applications at different sizes


How I Got Here

I saw a LinkedIn post the other day. It was a comparison of state management libraries ranked purely by bundle size. Context (0KB), Zustand (2.2KB), Redux (4.3KB).

I started writing a response, then realized I had too much to say. That comparison, while technically correct, misses everything.

The truth is, I've lived the full journey with all three. I've watched teams pick based on package size and regret it six months later. I've seen the opposite: teams over-engineer from day one and waste months fighting boilerplate. And I've seen teams pick exactly right and wonder why other teams struggle so much.

Over the years, I've watched teams struggle with the same questions:

  • Should we use Context or something heavier?
  • When does Zustand stop working and you need Redux?
  • How do you migrate when you've picked wrong?
  • Why does bundle size matter less than I thought it would?

This guide is what I wish someone had told me instead of just showing me a bar chart of package sizes. The right choice isn't about bytes. It's about use cases, team size, and complexity, and that's a much more interesting conversation.

Context API: When It Falls Apart

What It Is

The built-in React solution for prop drilling. No dependencies, uses React's own state management.

The Story

That LinkedIn post had me convinced. "0KB!" I thought. "Perfect."

I was 3 weeks into a new product with 15 components, and I decided to use React Context. No dependencies. Pure React. It made sense at the time.

Fast-forward six months. The app had grown to 200 components. A user toggled a feature flag, and the entire dashboard froze for 2-3 seconds. Profiling revealed something shocking: a single state change was triggering 180+ component re-renders. I spent three days trying to fix it with useMemo, memoization, and context splitting. By day three, I realized the 0KB saved at the start had cost me weeks of debugging and optimization later.

And here's the kicker: the bundle size difference never mattered. The actual cost wasn't in the bytes, it was in the developer time, the performance hit in production, and the stress of a frozen dashboard during a critical demo.

That experience taught me that choosing based on package size alone is like buying a car based on the weight of the paint job. You're optimizing for the wrong metric.

When to Use It

  1. Small feature flags - Theme switching, locale selection
  2. Infrequent updates - User data that changes rarely
  3. Zero dependencies requirement - Constrained environments
  4. Simple navigation state - Current page, sidebar open/closed
  5. Learning React - Teaching state management fundamentals

Critical Gotchas (Production Issues I've Hit)

1. Performance Death by Re-renders (The Big One)

// BAD: GOTCHA: All consumers re-render when ANY value changes
const Value = React.createContext(null);

function App() {
  const [user, setUser] = useState({ name: 'John', id: 1 });
  const [theme, setTheme] = useState('dark');
  const [language, setLanguage] = useState('en');

  return (
    <Value.Provider value={{ user, theme, language }}>
      <SlowComponent /> {/* Re-renders even if only 'theme' changed */}
    </Value.Provider>
  );
}

// Component with ANY consumer hook memoization won't help much
function SlowComponent() {
  const { theme } = useContext(Value);
  return <ExpensiveRender />; // Still re-renders!
}
Enter fullscreen mode Exit fullscreen mode

Why This Matters: I've seen dashboards with hundreds of components where changing a single flag caused full re-renders. Profiling showed 200ms of wasted renders per interaction.

The Fix (Partially):

// Split contexts by update frequency
const UserContext = React.createContext(null);
const ThemeContext = React.createContext(null);

// Even this isn't foolproof - descendants still re-render
// You need useMemo AND proper memoization everywhere
Enter fullscreen mode Exit fullscreen mode

Reality Check: This "fix" adds complexity and doesn't fully solve the problem. You'll spend more time optimizing than using a proper store.


2. Stale Closures in Async Operations

// BAD: GOTCHA: Closure captures stale context value
const MyContext = React.createContext(null);

function Component() {
  const contextValue = useContext(MyContext);

  useEffect(() => {
    // contextValue here is "frozen" when effect runs
    const timer = setTimeout(() => {
      console.log(contextValue); // May be stale!
    }, 5000);

    return () => clearTimeout(timer);
  }, []); // Empty deps = stale closure trap

  return <div>{contextValue.data}</div>;
}
Enter fullscreen mode Exit fullscreen mode

At Scale: Imagine analytics tracking with stale event context. You lose data integrity. Redux handles this better.


3. No Time-Travel Debugging

You cannot:

  • Inspect state history
  • Replay actions
  • See what changed and when
  • Debug why things updated

Redux DevTools shows you exactly what happened. Context gives you nothing.


4. Middle-Layer Subscribers Problem

// BAD: GOTCHA: Intermediate components always re-render
function Provider() {
  return (
    <ContextA.Provider value={a}>
      <ContextB.Provider value={b}>
        <Middleware /> {/* Always re-renders! */}
        <Content />
      </ContextB.Provider>
    </ContextA.Provider>
  );
}
Enter fullscreen mode Exit fullscreen mode

Redux separates subscription logic entirely from rendering. Context doesn't.


5. No Computed/Derived State Management

// BAD: With Context, you must manually manage derived state
const UserContext = React.createContext(null);

function App() {
  const [users, setUsers] = useState([]);

  // This memo is easy to forget or get wrong
  const sortedUsers = useMemo(() => 
    users.slice().sort((a, b) => a.name.localeCompare(b.name)),
    [users]
  );

  return (
    <UserContext.Provider value={{ users, sortedUsers }}>
      {/* Prop drilling or manual memo in 50 places */}
    </UserContext.Provider>
  );
}
Enter fullscreen mode Exit fullscreen mode

Redux has selectors. Zustand has derived selectors. Context just has prop drilling.


6. Testing is a Nightmare

// With Context, you must wrap every test component
function renderWithContext(component) {
  return render(
    <UserContext.Provider value={mockValue}>
      <ThemeContext.Provider value={mockTheme}>
        <LocaleContext.Provider value={mockLocale}>
          {component}
        </LocaleContext.Provider>
      </ThemeContext.Provider>
    </UserContext.Provider>
  );
}
Enter fullscreen mode Exit fullscreen mode

This boilerplate is fragile. One missing provider and your test fails mysteriously.


7. Library Integration Hell

// Third-party UI libraries don't integrate well
// They expect Redux or managed state, not Context
// You end up lifting state up constantly
Enter fullscreen mode Exit fullscreen mode

In my experience, integrating UI libraries with Context was painful. Redux is an industry standard.


When NOT to Use Context

  • Any realtime data (multiple updates per second)
  • Complex app logic or derived state
  • Team larger than 3-4 people
  • Performance-critical features
  • Any app where you'll want debugging

Zustand: The Sleeping Giant

What It Is

Lightweight store library inspired by Jotai. Single function to create stores. Minimal boilerplate.

The Story

After my Context disaster, I wasn't ready for Redux. The boilerplate seemed excessive for a team of five. Then I discovered Zustand, and it felt like relief.

Zustand let me write state management code that felt natural. No action types, no reducers, no 500 lines of middleware setup. I could ship features fast. The bundle size was tiny. Performance was excellent.

For the next two years, it was my go-to choice. I built three different products with it, and each one scaled smoothly up to about 200 to 300 components. Then I hit the ceiling.

On one project, different team members started handling async operations in completely different ways. One person used callbacks, another used promises, and a third added a custom middleware pattern. No two API calls worked the same way. The lack of enforced structure that felt so freeing at the start became a nightmare to maintain.

It wasn't Zustand's fault. It's just that Zustand is extremely flexible, and without discipline, that flexibility becomes chaos.

When to Use It

  1. Mid-size applications - 50-500 components
  2. Moderate complexity - More than Context, less than Redux
  3. Teams 5-15 people - Easy to understand, zero magic
  4. Performance matters - Zero wasted renders
  5. Fast MVPs - Get to market quickly
  6. TypeScript shops - Excellent type inference

Real-World Example

// Store definition - it's just a hook
import create from 'zustand';

const useUserStore = create((set) => ({
  user: null,
  theme: 'light',

  // Actions are just functions
  setUser: (user) => set({ user }),
  setTheme: (theme) => set({ theme }),

  // Reset
  reset: () => set({ user: null, theme: 'light' }),
}));

// Usage - simple and clean
function App() {
  const user = useUserStore((state) => state.user);
  const setUser = useUserStore((state) => state.setUser);

  return <div>{user?.name}</div>;
}
Enter fullscreen mode Exit fullscreen mode

Gotchas (Real Production Issues)

1. Subscription Explosion Without Proper Selectors

// BAD: GOTCHA: Re-renders even with selector if you're not careful
const useStore = create((set) => ({
  user: { name: 'John', email: 'john@ex.com' },
  settings: { theme: 'dark' }
}));

function Component() {
  // This seems fine...
  const { user } = useStore();

  // But useStore() creates a new object every time!
  // Better approach below
}

// GOOD: Better
function Component() {
  // Selector gets only what you need, memoized properly
  const userName = useStore((state) => state.user.name);

  // Or use shallow equality for objects
  const user = useStore((state) => state.user);
}
Enter fullscreen mode Exit fullscreen mode

In Practice: I had to teach my team about selector patterns. Sloppy usage leads to performance problems.


2. Missing Middleware/Plugins Pattern

// Redux has middleware built-in for side effects
// Zustand... you handle it yourself
const useStore = create((set, get) => ({
  savedItems: [],

  // Side effects manually baked in
  addItem: async (item) => {
    // You manage async, persistence, logging, etc.
    const saved = await api.save(item);
    set((state) => ({ savedItems: [...state.savedItems, saved] }));

    // Where does logging go? Where do you centralize error handling?
  }
}));
Enter fullscreen mode Exit fullscreen mode

This freedom is great until your codebase has 15 different patterns for the same thing.


3. DevTools Integration Not as Rich as Redux

// Redux DevTools is built for Redux
// Zustand has plugins, but they're not as powerful
import { devtools } from 'zustand/middleware';

const useStore = devtools(
  create((set) => ({
    // ...
  }))
);

// Works, but the UI history isn't as detailed
// Doesn't show you middleware flow
Enter fullscreen mode Exit fullscreen mode

In Practice: Redux DevTools integration is industry-standard. Zustand's is good, not exceptional.


4. No Built-in Action/Reducer Pattern

// Redux gives you:
// reducer(state, action) -> newState with complete history

// Zustand gives you:
// set() anywhere you want - flexibility but no structure
const useStore = create((set) => ({
  counter: 0,
  increment: () => set((state) => ({ counter: state.counter + 1 })),
  // vs
  decrement: () => set((state) => ({ counter: state.counter - 1 })),

  // After 50+ actions, there's no clear pattern
  // Redux enforces type safety on action flow
}));
Enter fullscreen mode Exit fullscreen mode

Scaling Problem: As stores grow, you lose the "single source of truth" for actions that Redux gives you.


5. Async Complexity Grows Quickly

// BAD: Zustand + async = manual state management
const useUserStore = create((set, get) => ({
  users: [],
  loading: false,
  error: null,
  timestamp: null,

  fetchUsers: async () => {
    set({ loading: true, error: null });
    try {
      const data = await api.getUsers();
      set({ users: data, loading: false, timestamp: Date.now() });
    } catch (err) {
      set({ error: err.message, loading: false });
    }
  },

  // Now multiply this by 20 endpoints...
  // You'll see inconsistencies appear
}));

// RTK Query would handle all of this with a single endpoint definition
// Including caching, deduplication, and automatic refetching
Enter fullscreen mode Exit fullscreen mode

In Practice: For async-heavy apps (95% of real apps), RTK Query handles caching, deduplication, and refetching out of the box. With Zustand, you build all of that yourself, and it rarely ends up consistent across the codebase.


6. No Clear Testing Story for Complex State

// Testing Zustand stores is simple for happy path
// Complex scenarios get messy
test('complex user flow', () => {
  const { result } = renderHook(() => useUserStore());

  // But what about state management edge cases?
  // Concurrent updates? Race conditions?
  // Redux DevTools helps you see the issue
});
Enter fullscreen mode Exit fullscreen mode

7. Immer Middleware Isn't Always Applied

// BAD: GOTCHA: You can mutate state if not careful
const useStore = create(
  immer((set) => ({
    user: { name: 'John', nested: { email: 'j@ex.com' } },

    // This might seem fine:
    updateEmail: (email) => set((state) => {
      state.user.nested.email = email; // Only works with immer!
    })
  }))
);

// Without immer, you MUST use spread operators
// Easy to forget, causes bugs
Enter fullscreen mode Exit fullscreen mode

Sweet Spot for Zustand

Best when:

  • You want Redux-like power without the boilerplate
  • Team is 5-20 people
  • App complexity is medium
  • You need great TypeScript support
  • Performance is important but not the #1 concern
  • DevOps overhead matters

Don't use when:

  • Your app is huge (1000+ components), where Redux's structure helps
  • Your team needs strict patterns, since Zustand is too flexible
  • You have heavy async or side effects, where Redux middleware is better
  • Browser DevTools debugging is critical

Redux: The Industry Standard

What It Is

Predictable state container with immutable updates, action dispatch, and reducers. The most mature ecosystem.

The Story

I resisted Redux for years. The boilerplate felt ridiculous for most use cases. For simple apps, it seemed like enterprise overkill. But then I joined a team working on a more complex application with eight engineers, and the lead insisted on Redux.

I was skeptical. Setting up Redux Toolkit, configuring selectors, writing tests: it all felt slow. But something interesting happened around week 3.

When a subtle bug appeared involving async state, the Redux DevTools made it visible in seconds. I could see exactly which actions dispatched and when, inspect the state at any point, and even replay the sequence of events. What would have been an hour-long debugging session with Zustand took ten minutes.

More importantly, everyone wrote async code the same way. The team used thunks consistently. Error handling was predictable. New team members could look at one example and understand the pattern for everything else.

The boilerplate felt less painful when I realized it wasn't boilerplate. It was structure. Structure that caught bugs at the source instead of letting them hide in production.

When to Use It

  1. Large applications - 500+ components
  2. Complex state logic - Lots of derived/computed state
  3. Team of 10+ people - Enforce consistent patterns
  4. Mission-critical data - E-commerce, financial
  5. Browser DevTools requirement - Time travel, action history
  6. Mature ecosystem needed - Libraries, middleware, plugins
  7. Async operations - RTK Query, createAsyncThunk, or Sagas

Real-World Example (Redux Toolkit)

// 1. Slice with createAsyncThunk - handles loading/error/success automatically
import { createSlice, createAsyncThunk } from '@reduxjs/toolkit';

export const fetchUsers = createAsyncThunk(
  'users/fetchUsers',
  async (_, { rejectWithValue }) => {
    try {
      const response = await api.getUsers();
      return response.data;
    } catch (err) {
      return rejectWithValue(err.message);
    }
  }
);

const usersSlice = createSlice({
  name: 'users',
  initialState: { list: [], loading: false, error: null },
  reducers: {
    // Sync actions auto-generated
    clearUsers: (state) => { state.list = []; },
  },
  extraReducers: (builder) => {
    builder
      .addCase(fetchUsers.pending, (state) => {
        state.loading = true;
        state.error = null;
      })
      .addCase(fetchUsers.fulfilled, (state, action) => {
        state.list = action.payload;
        state.loading = false;
      })
      .addCase(fetchUsers.rejected, (state, action) => {
        state.error = action.payload;
        state.loading = false;
      });
  },
});

export const { clearUsers } = usersSlice.actions;
export default usersSlice.reducer;

// 2. Selectors (memoized)
export const selectUsers = (state) => state.users.list;
export const selectLoading = (state) => state.users.loading;
export const selectSortedUsers = createSelector(
  [selectUsers],
  (users) => users.slice().sort((a, b) => a.name.localeCompare(b.name))
);

// 3. Usage in component
function UsersList() {
  const dispatch = useDispatch();
  const users = useSelector(selectSortedUsers);
  const loading = useSelector(selectLoading);

  useEffect(() => { dispatch(fetchUsers()); }, [dispatch]);

  if (loading) return <Spinner />;
  return users.map(u => <UserCard key={u.id} user={u} />);
}
Enter fullscreen mode Exit fullscreen mode

Real-World Example (RTK Query): The Modern Async Story

This is where Redux really shines now. RTK Query eliminates almost all async boilerplate:

// 1. Define API: one file, all endpoints
import { createApi, fetchBaseQuery } from '@reduxjs/toolkit/query/react';

export const usersApi = createApi({
  reducerPath: 'usersApi',
  baseQuery: fetchBaseQuery({ baseUrl: '/api' }),
  tagTypes: ['User'],
  endpoints: (builder) => ({
    getUsers: builder.query({
      query: () => '/users',
      providesTags: ['User'],
    }),
    getUserById: builder.query({
      query: (id) => `/users/${id}`,
      providesTags: (result, error, id) => [{ type: 'User', id }],
    }),
    createUser: builder.mutation({
      query: (newUser) => ({
        url: '/users',
        method: 'POST',
        body: newUser,
      }),
      invalidatesTags: ['User'], // Auto-refetches getUsers!
    }),
    updateUser: builder.mutation({
      query: ({ id, ...patch }) => ({
        url: `/users/${id}`,
        method: 'PATCH',
        body: patch,
      }),
      invalidatesTags: (result, error, { id }) => [{ type: 'User', id }],
    }),
  }),
});

export const {
  useGetUsersQuery,
  useGetUserByIdQuery,
  useCreateUserMutation,
  useUpdateUserMutation,
} = usersApi;

// 2. Store setup: one line to add
const store = configureStore({
  reducer: {
    [usersApi.reducerPath]: usersApi.reducer,
  },
  middleware: (getDefault) => getDefault().concat(usersApi.middleware),
});

// 3. Usage: look how clean this is
function UsersList() {
  const { data: users, isLoading, error } = useGetUsersQuery();
  const [createUser] = useCreateUserMutation();

  if (isLoading) return <Spinner />;
  if (error) return <Error message={error.message} />;

  return (
    <>
      <button onClick={() => createUser({ name: 'New User' })}>
        Add User
      </button>
      {users.map(u => <UserCard key={u.id} user={u} />)}
    </>
  );
}

// What you get for free:
// GOOD: Automatic caching
// GOOD: Cache invalidation on mutations
// GOOD: Deduplication of identical requests
// GOOD: Loading/error/success states
// GOOD: Polling & refetch on focus
// GOOD: Optimistic updates
// GOOD: Full DevTools visibility
Enter fullscreen mode Exit fullscreen mode

Why RTK Query changed the game: Compare this to the Zustand async example above. RTK Query gives you caching, deduplication, automatic refetching, and cache invalidation, all things you'd have to build yourself in Zustand. For any app with more than five API endpoints, this saves weeks of work.

Gotchas (Even Redux Has Them)

1. Boilerplate Explosion

// For ONE feature, you need:
// 1. Action types (3+ constants)
// 2. Action creators (maybe 3-5 functions)
// 3. Reducer logic (switch statement, spreads, etc.)
// 4. Selectors (multiple, with reselect)
// 5. Middleware (if async)

// This scales poorly. What should take 10 lines takes 200+
// I've seen teams spending 30% of their time fighting Redux structure
Enter fullscreen mode Exit fullscreen mode

2. RTK Reduces Boilerplate, But Doesn't Eliminate Complexity

// RTK + RTK Query dramatically cuts boilerplate
// But you STILL need to understand:
// - configureStore() setup
// - useSelector / useDispatch hooks
// - Selector memoization for derived data
// - Tag-based cache invalidation logic (RTK Query)
// - Middleware pipeline (RTK Query adds its own)

// RTK Query specifically has its own learning curve:
// - providesTags / invalidatesTags for cache management
// - Optimistic updates require onQueryStarted callbacks
// - Polling, prefetching, and cache lifetime tuning

// It's significantly better than vanilla Redux, but the
// mental model is still heavier than Zustand's "just call set()"
Enter fullscreen mode Exit fullscreen mode

3. Normalized State Complexity

// Redux demands normalized state for performance
// Normalized = harder to reason about

// BAD: Simple but slow:
{
  users: [
    { id: 1, name: 'John', posts: [{ id: 1, title: '...' }, ...] }
  ]
}

// GOOD: Redux way (normalized):
{
  entities: {
    users: {
      '1': { id: 1, name: 'John', postIds: [1, 2] }
    },
    posts: {
      '1': { id: 1, title: '...', userId: 1 },
      '2': { id: 2, title: '...', userId: 1 }
    }
  },
  result: [1]
}

// This is more efficient but requires EntityAdapters and deep selectors
// New developers struggle with this structure
Enter fullscreen mode Exit fullscreen mode

4. Action Coupling in Teams

// BAD: GOTCHA: Different teams duplicate action logic
// Team A does: dispatch({ type: 'UPDATE_USER', payload: {...} })
// Team B does: dispatch({ type: 'MODIFY_USER', payload: {...} })
// Team C does: dispatch({ type: 'PATCH_USER', payload: {...} })

// After 3 months, you have 500+ incompatible action types
// Redux doesn't prevent this - discipline does

// Redux Toolkit's slices help, but only if enforced strictly
Enter fullscreen mode Exit fullscreen mode

5. Middleware Ordering Issues

// BAD: GOTCHA: Middleware order matters, isn't obvious
const store = configureStore({
  reducer: rootReducer,
  middleware: (getDefaultMiddleware) =>
    getDefaultMiddleware()
      .concat(loggingMiddleware)
      .concat(analyticsMiddleware)
      .concat(crashReportingMiddleware),
});

// If one middleware fails, order affects behavior
// I've spent hours debugging middleware chain ordering issues
// Redux DevTools helps but requires deep knowledge
Enter fullscreen mode Exit fullscreen mode

6. Selector Memoization is Fragile

// BAD: Forget reselect, lose performance with derived data
const selectSortedUsers = (state) => 
  state.users.list.slice().sort((a, b) => a.name.localeCompare(b.name)); 
  // ^ New array created every call = re-render every time!

// GOOD: Must use reselect for derived/computed data
import { createSelector } from 'reselect';

const selectSortedUsers = createSelector(
  (state) => state.users.list,
  (users) => users.slice().sort((a, b) => a.name.localeCompare(b.name))
  // Only recomputes when users.list reference changes
);

// Simple property access is fine without reselect:
// const selectUser = (state) => state.users.user; // GOOD: No new object
// But any transformation or filtering NEEDS memoization
// Many Redux codebases have subtle performance bugs from missing this
Enter fullscreen mode Exit fullscreen mode

7. DevTools Addiction

// Redux DevTools are amazing... until they're not
// You get:
// GOOD: Time travel debugging
// GOOD: Action history
// GOOD: State snapshots
// BAD: But NOT: Why did this re-render? (React DevTools helps)
// BAD: And NOT: Why did this action dispatch? (Needs middleware logging)

// Teams become dependent on DevTools
// Then they deploy to production where DevTools don't work
// Require different debugging skills for production issues
Enter fullscreen mode Exit fullscreen mode

When Redux Actually Shines

Redux wins when:

  • State is complex and interconnected
  • 10+ people on the team (enforce patterns)
  • Async operations dominate (middleware ecosystem)
  • DevTools debugging is critical
  • Historical action tracking is needed
  • Normalized data is necessary

Head-to-Head Comparison: Real Scenarios

Scenario 1: Simple Counter

// Context: 15 lines
const CounterContext = React.createContext(0);
function App() {
  const [count, setCount] = useState(0);
  return (
    <CounterContext.Provider value={{ count, setCount }}>
      <Counter />
    </CounterContext.Provider>
  );
}

// Zustand: 8 lines
const useCounter = create((set) => ({
  count: 0,
  increment: () => set((state) => ({ count: state.count + 1 }))
}));

// Redux: 60+ lines
// Winner: Context/Zustand
Enter fullscreen mode Exit fullscreen mode

Scenario 2: Real-Time Dashboard

Requirements:
- 200+ components
- 30+ data sources
- Real-time updates (10+ per second)
- Derived/filtered views of data
- Time-travel debugging needed
Enter fullscreen mode Exit fullscreen mode

Context: Complete failure (re-render storm)
Zustand: Works but requires discipline on selectors, async patterns inconsistent
Redux: Handles perfectly, DevTools essential for debugging


Scenario 3: E-commerce Checkout

Requirements:
- Complex state (cart, user, shipping, payment, etc.)
- Lots of validations
- Async calls for everything
- Multiple integrations
- Team of 15 people
Enter fullscreen mode Exit fullscreen mode

Context: Impossible to coordinate
Zustand: Works but async patterns will diverge across team
Redux: Enforces consistency, middleware handles all integrations


Scenario 4: Prototype/MVP

Requirements:
- Ship in 2 weeks
- Small team (3 people)
- Theme toggle, auth, minimal state
Enter fullscreen mode Exit fullscreen mode

Context: Fast, no setup
Zustand: Faster, scales if product works
Redux: Overkill, slow to ship


Performance Deep Dive

Context Re-render Issue (Numbers)

App with 500 components
Updating one data: User email

Context API:
- If context splits by concern: ~100 re-renders
- If unified: ~300 re-renders
- Time: 85-150ms

Zustand (proper selectors):
- Re-renders: ~5-10 (only subscribed components)
- Time: 2-5ms

Redux (with reselect):
- Re-renders: ~5-10
- Time: 2-5ms
Enter fullscreen mode Exit fullscreen mode

Real Impact: This was a 10x performance difference in real production dashboards I've worked on.


Developer Experience & Learning Curve

Context

  • Learning: 1 day
  • Productive: 2 days
  • Reality: Hits wall at medium complexity (1-2 weeks)

Zustand

  • Learning: 3 days
  • Productive: 5 days
  • Reality: Scales to medium complexity smoothly

Redux

  • Learning: 1 week
  • Productive: 3 weeks
  • Reality: Complexity hidden by structure, scales very far

The Real Decision Matrix

Choose CONTEXT when:
- Single-person project
- Learning React fundamentals
- Simple configuration/theme state
- Performance is NOT critical
- Just teaching, not shipping

Choose ZUSTAND when:
- Small-to-medium team (3-15 people)
- Moderate complexity
- Want something "just right"
- TypeScript matters
- Performance important but not critical
- Need flexibility

Choose REDUX when:
- Large team (10+ people)
- Complex application logic
- Async operations are core
- DevTools debugging is important
- Historical action tracking needed
- Normalizing data is necessary
- Performance is critical
- Long-term maintenance matters
Enter fullscreen mode Exit fullscreen mode

Anti-Patterns I've Seen

Context Anti-Pattern: Global Context for Everything

// Common anti-pattern: One context with entire app state
const AppContext = React.createContext({
  user: {},
  theme: 'light',
  notifications: [],
  filters: {},
  modal: {},
  // ... 30 more things
});

// Result: Every change floods entire app with re-renders
// Solution: Split by update frequency (but defeats the purpose)
Enter fullscreen mode Exit fullscreen mode

Zustand Anti-Pattern: Store Soup

// Multiple Zustand stores with cross-store dependencies
const useUserStore = create(...);
const useFilterStore = create(...);
const useModalStore = create(...);

// In component:
const user = useUserStore();
const filters = useFilterStore();
const modal = useModalStore();

// Now updates are scattered across stores
// No single source of truth
// Debugging nightmare
Enter fullscreen mode Exit fullscreen mode

Redux Anti-Pattern: God Reducer

// One massive reducer doing everything
const rootReducer = (state, action) => {
  // 5000 lines of switch/case
  // Impossible to maintain
}
Enter fullscreen mode Exit fullscreen mode

Recommendations from Experience

If You're at a Startup

Use Zustand. I've seen startups ship products in weeks with it and then scale smoothly. You get simplicity when you need it and room to grow when success hits.

If You're at a Scaleup (50-500 people)

Use Zustand until you hit a wall, then plan the migration to Redux. This is your window. Build with Zustand, but document your state patterns from day one.

If You're at a Larger Organization

Consider splitting: Context for truly local state (modals, UI toggles), Zustand for features, Redux for platform-wide concerns if needed. But honestly, pick one and stick with it.

If You're on a Large Team

Redux (or a similar enforced pattern). Individual freedom causes integration chaos every time. I've seen it happen at various scales. The structure matters more than saving a few lines of code.

If You're Building an App That'll Live 5+ Years

Redux, but do it right. The initial investment pays dividends when you're debugging why something broke at 2 AM in year 3 and Redux DevTools shows you exactly what happened.

If You're Teaching or Mentoring

Start with Context to explain the fundamentals, then show Zustand as the practical choice, and mention Redux as the enterprise standard. Don't let them learn Redux first because it obscures the core concepts.

If You're Joining an Existing Team

Use what they're using. Consistency matters more than optimal choices. The best tool is the one your team already knows.


Migration Paths

Context → Zustand (Easy)

// Mostly 1:1 replacement
// Context Provider → Zustand create()
// useContext → useStore
// 2-3 days for medium app
Enter fullscreen mode Exit fullscreen mode

Zustand → Redux (Painful)

// Requires restructuring everything
// Actions → Action types
// Store functions → Reducers
// Async handling changes
// 2-3 weeks for medium app
// Potential bugs in process
Enter fullscreen mode Exit fullscreen mode

Redux → Zustand (Possible but risky)

// Most Redux patterns don't translate
// You lose DevTools power
// Not recommended for production apps
Enter fullscreen mode Exit fullscreen mode

Final Verdict

The Honest Truth

Context API is a footgun masquerading as a feature. I've seen so many teams start with it and regret it by month two. Use it for theme switching and that's it.

Zustand is the Goldilocks solution for most teams. It's what I reach for when starting new projects. If unsure, start here. You'll move fast, and if you outgrow it, you have room to restructure.

Redux is worth every line of boilerplate if your team commits to using it properly. I've seen it save teams countless debugging hours once they got past the learning curve. The structure it enforces becomes a feature, not a bug.

The lesson I learned the hard way: prematurely optimizing for "simplicity" leads to painful refactoring later. The best choice isn't the one that sounds easiest at day one. It's the one that scales with your team and keeps working as your application grows more complex.

Start small. But think ahead.


References

  • Redux Essentials: redux.js.org
  • Zustand Docs: zustand-demo.vercel.app
  • React Context Gotchas: Various team retrospectives
  • Real-world performance data: Production app profiling across multiple teams

Top comments (1)

Collapse
 
ashu_agarwal profile image
Ashu agarwal

Really like how thoughtfully you structured this. You addressed all the key questions that might come up during this decision-making process, and the reasoning throughout was clear and well explained.