React State Management: Context, Redux, Zustand

React's component-based architecture is powerful, but it creates a fundamental problem: how do you share state between components that aren't directly related? Prop drilling—passing props through...

Key Insights

  • Context API excels for simple, localized state sharing but suffers from performance issues at scale due to unnecessary re-renders
  • Redux remains the best choice for large applications requiring strict patterns, time-travel debugging, and predictable state updates across complex feature sets
  • Zustand delivers the sweet spot for most modern React apps: minimal boilerplate, excellent performance, and a developer experience that doesn’t sacrifice power for simplicity

The State Management Landscape

React’s component-based architecture is powerful, but it creates a fundamental problem: how do you share state between components that aren’t directly related? Prop drilling—passing props through multiple layers of components—quickly becomes unmaintainable in real applications.

You need dedicated state management when you have global state (user authentication, theme preferences), state shared across distant components, or complex state logic that doesn’t belong in a single component. The question isn’t whether you need state management, but which solution fits your specific requirements.

React Context API: Built-in Simplicity

Context API is React’s native solution for avoiding prop drilling. It creates a “wormhole” through your component tree, allowing deeply nested components to access shared state without threading props through every level.

Here’s a practical theme switcher implementation:

import { createContext, useContext, useState } from 'react';

const ThemeContext = createContext();

export function ThemeProvider({ children }) {
  const [theme, setTheme] = useState('light');
  
  const toggleTheme = () => {
    setTheme(prev => prev === 'light' ? 'dark' : 'light');
  };
  
  return (
    <ThemeContext.Provider value={{ theme, toggleTheme }}>
      {children}
    </ThemeContext.Provider>
  );
}

export function useTheme() {
  const context = useContext(ThemeContext);
  if (!context) {
    throw new Error('useTheme must be used within ThemeProvider');
  }
  return context;
}

Consuming this context is straightforward:

function Header() {
  const { theme, toggleTheme } = useTheme();
  
  return (
    <header className={theme}>
      <button onClick={toggleTheme}>
        Switch to {theme === 'light' ? 'dark' : 'light'} mode
      </button>
    </header>
  );
}

For more complex state logic, combine Context with useReducer:

import { createContext, useContext, useReducer } from 'react';

const CartContext = createContext();

function cartReducer(state, action) {
  switch (action.type) {
    case 'ADD_ITEM':
      return { ...state, items: [...state.items, action.payload] };
    case 'REMOVE_ITEM':
      return { 
        ...state, 
        items: state.items.filter(item => item.id !== action.payload) 
      };
    case 'CLEAR_CART':
      return { ...state, items: [] };
    default:
      return state;
  }
}

export function CartProvider({ children }) {
  const [state, dispatch] = useReducer(cartReducer, { items: [] });
  
  return (
    <CartContext.Provider value={{ state, dispatch }}>
      {children}
    </CartContext.Provider>
  );
}

The problem with Context? Every consumer re-renders when any part of the context value changes. This becomes a performance bottleneck in large applications. You can mitigate this with careful value memoization and splitting contexts, but you’re fighting React’s design rather than working with it.

Redux: The Enterprise Standard

Redux enforces a strict unidirectional data flow: actions describe what happened, reducers specify how state changes, and a single store holds your entire application state. This predictability makes Redux excellent for large teams and complex applications.

Modern Redux means Redux Toolkit (RTK), which eliminates the boilerplate that gave Redux a bad reputation:

import { createSlice, configureStore } from '@reduxjs/toolkit';

const userSlice = createSlice({
  name: 'user',
  initialState: {
    profile: null,
    status: 'idle',
    error: null
  },
  reducers: {
    logout: (state) => {
      state.profile = null;
    }
  }
});

export const { logout } = userSlice.actions;

const store = configureStore({
  reducer: {
    user: userSlice.reducer
  }
});

export default store;

Component usage is clean with hooks:

import { useSelector, useDispatch } from 'react-redux';
import { logout } from './store';

function UserProfile() {
  const profile = useSelector(state => state.user.profile);
  const dispatch = useDispatch();
  
  return (
    <div>
      <h2>{profile?.name}</h2>
      <button onClick={() => dispatch(logout())}>Logout</button>
    </div>
  );
}

For async operations, createAsyncThunk handles the loading states automatically:

import { createSlice, createAsyncThunk } from '@reduxjs/toolkit';

export const fetchUser = createAsyncThunk(
  'user/fetchUser',
  async (userId) => {
    const response = await fetch(`/api/users/${userId}`);
    return response.json();
  }
);

const userSlice = createSlice({
  name: 'user',
  initialState: {
    profile: null,
    status: 'idle',
    error: null
  },
  reducers: {
    logout: (state) => {
      state.profile = null;
    }
  },
  extraReducers: (builder) => {
    builder
      .addCase(fetchUser.pending, (state) => {
        state.status = 'loading';
      })
      .addCase(fetchUser.fulfilled, (state, action) => {
        state.status = 'succeeded';
        state.profile = action.payload;
      })
      .addCase(fetchUser.rejected, (state, action) => {
        state.status = 'failed';
        state.error = action.error.message;
      });
  }
});

Redux shines in applications with complex state interactions, strict audit requirements, or teams that benefit from enforced patterns. The Redux DevTools extension provides time-travel debugging that’s genuinely useful in production debugging.

Zustand: Minimalist Powerhouse

Zustand takes a radically simpler approach. No providers, no reducers, no actions—just a hook that returns state and functions to update it. Despite this simplicity, it handles everything Redux does, often more elegantly.

Creating a store is remarkably concise:

import create from 'zustand';

const useStore = create((set) => ({
  count: 0,
  user: null,
  increment: () => set((state) => ({ count: state.count + 1 })),
  setUser: (user) => set({ user }),
  reset: () => set({ count: 0, user: null })
}));

Using it in components requires no wrapper components:

function Counter() {
  const count = useStore(state => state.count);
  const increment = useStore(state => state.increment);
  
  return <button onClick={increment}>{count}</button>;
}

Zustand’s selector approach prevents unnecessary re-renders. This component only re-renders when count changes, not when user updates.

Async actions need no special handling:

const useStore = create((set) => ({
  users: [],
  loading: false,
  fetchUsers: async () => {
    set({ loading: true });
    const response = await fetch('/api/users');
    const users = await response.json();
    set({ users, loading: false });
  }
}));

Middleware adds powerful features with minimal configuration:

import create from 'zustand';
import { persist, devtools } from 'zustand/middleware';

const useStore = create(
  devtools(
    persist(
      (set) => ({
        theme: 'light',
        toggleTheme: () => set((state) => ({ 
          theme: state.theme === 'light' ? 'dark' : 'light' 
        }))
      }),
      { name: 'app-storage' }
    )
  )
);

This store automatically persists to localStorage and integrates with Redux DevTools—features that require significant setup in Redux.

Performance Comparison

Context API’s performance degrades with scale. Every consumer re-renders on any context change, regardless of what data they actually use. You can split contexts or use useMemo, but you’re adding complexity to work around the limitation.

Redux with React-Redux v7+ uses a subscription model that prevents unnecessary re-renders when you select specific state slices. The performance is excellent, but the bundle size impact is significant—around 20KB minified.

Zustand delivers the best of both worlds: selective re-renders like Redux with a bundle size under 2KB. In benchmarks, Zustand consistently outperforms both Context and Redux in re-render frequency and update speed.

Here’s a simple benchmark setup:

// Test: Update one property, measure re-renders in 100 components
// Context: 100 re-renders
// Redux: 10 re-renders (only components selecting that property)
// Zustand: 10 re-renders (only components selecting that property)

Decision Framework: Which Tool to Choose

Use Context API when:

  • You have simple, localized state (theme, locale, authentication status)
  • Your state changes infrequently
  • You want zero dependencies beyond React
  • Your application is small (< 10 components accessing shared state)

Use Zustand when:

  • You need global state without ceremony
  • Performance matters and you want minimal bundle size
  • You’re building a modern application without legacy constraints
  • You want great DevTools without configuration overhead
  • Your team values developer experience and maintainability

Use Redux when:

  • You’re working on a large enterprise application
  • You need strict patterns and predictable state updates
  • Time-travel debugging and state inspection are critical
  • You’re integrating with existing Redux ecosystems
  • Your team already knows Redux and benefits from its constraints

Migration is straightforward in all directions. Start with Context, migrate to Zustand when you hit performance issues, and move to Redux only if you need its specific features. Don’t choose Redux because it’s “industry standard”—choose it because your application actually needs what it provides.

No One-Size-Fits-All

The React ecosystem’s diversity in state management solutions is a feature, not a bug. Context API, Redux, and Zustand each excel in different scenarios. Context is perfect for simple shared state, Redux provides structure for complex applications, and Zustand delivers modern developer experience with minimal overhead.

Most new projects should start with Zustand. It scales well, performs excellently, and doesn’t lock you into patterns you might not need. Reserve Redux for applications that genuinely benefit from its strict architecture, and use Context for truly simple cases where adding a dependency feels like overkill.

The best state management solution is the one that solves your actual problems without introducing unnecessary complexity. Choose based on your requirements, not trends.

Liked this? There's more.

Every week: one practical technique, explained simply, with code you can use immediately.