Skip to content
Codeloom
React

React State Management: Context vs Redux vs Zustand

Compare React state management solutions: Context API, Redux Toolkit, and Zustand. Learn when to use each with practical examples and trade-offs.

·8 min read · By Codeloom
Intermediate 15 min read

What you'll learn

  • How Context API works and when it falls short
  • Setting up Redux Toolkit for scalable state
  • Using Zustand for simple, performant global state
  • Choosing the right solution for your project

Prerequisites

  • React hooks (useState, useContext, useReducer)
  • Basic understanding of state management concepts

Every React app needs to decide how to share state between components. React gives you Context API built-in, but the ecosystem offers alternatives like Redux Toolkit and Zustand. Each has different trade-offs around complexity, performance, and developer experience.

This guide compares all three with the same feature built in each, so you can see the real differences.

The Problem: Shared State

Consider a shopping cart. Multiple components need access to the cart: the navbar badge, the cart drawer, product cards, and the checkout page. Passing props through every level is not practical.

You need a way to make cart state accessible anywhere in your component tree. Let us build it with each approach.

Context API

Context is built into React. No extra packages needed.

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

interface CartItem {
  id: string;
  name: string;
  price: number;
  quantity: number;
}

interface CartState {
  items: CartItem[];
  total: number;
}

type CartAction =
  | { type: 'ADD_ITEM'; payload: Omit<CartItem, 'quantity'> }
  | { type: 'REMOVE_ITEM'; payload: string }
  | { type: 'UPDATE_QUANTITY'; payload: { id: string; quantity: number } }
  | { type: 'CLEAR' };

function cartReducer(state: CartState, action: CartAction): CartState {
  switch (action.type) {
    case 'ADD_ITEM': {
      const existing = state.items.find(i => i.id === action.payload.id);
      const items = existing
        ? state.items.map(i =>
            i.id === action.payload.id
              ? { ...i, quantity: i.quantity + 1 }
              : i
          )
        : [...state.items, { ...action.payload, quantity: 1 }];
      return { items, total: items.reduce((sum, i) => sum + i.price * i.quantity, 0) };
    }
    case 'REMOVE_ITEM': {
      const items = state.items.filter(i => i.id !== action.payload);
      return { items, total: items.reduce((sum, i) => sum + i.price * i.quantity, 0) };
    }
    case 'UPDATE_QUANTITY': {
      const items = state.items.map(i =>
        i.id === action.payload.id
          ? { ...i, quantity: action.payload.quantity }
          : i
      );
      return { items, total: items.reduce((sum, i) => sum + i.price * i.quantity, 0) };
    }
    case 'CLEAR':
      return { items: [], total: 0 };
    default:
      return state;
  }
}

const CartContext = createContext<{
  state: CartState;
  dispatch: React.Dispatch<CartAction>;
} | null>(null);

export function CartProvider({ children }: { children: ReactNode }) {
  const [state, dispatch] = useReducer(cartReducer, { items: [], total: 0 });

  return (
    <CartContext.Provider value={{ state, dispatch }}>
      {children}
    </CartContext.Provider>
  );
}

export function useCart() {
  const context = useContext(CartContext);
  if (!context) {
    throw new Error('useCart must be used within a CartProvider');
  }
  return context;
}

Using it in components:

function CartBadge() {
  const { state } = useCart();
  const itemCount = state.items.reduce((sum, item) => sum + item.quantity, 0);

  return <span className="badge">{itemCount}</span>;
}

function AddToCartButton({ product }: { product: Product }) {
  const { dispatch } = useCart();

  return (
    <button onClick={() => dispatch({ type: 'ADD_ITEM', payload: product })}>
      Add to Cart
    </button>
  );
}

Context API strengths: No dependencies, simple for small state, built into React.

Context API weaknesses: Every consumer re-renders when any part of the context value changes. If your cart has 20 items and you update one quantity, every component using useCart() re-renders, even CartBadge which only needs the count.

Redux Toolkit

Redux Toolkit is the modern way to use Redux. It eliminates the boilerplate that made old Redux painful.

npm install @reduxjs/toolkit react-redux
import { configureStore, createSlice, type PayloadAction } from '@reduxjs/toolkit';

interface CartItem {
  id: string;
  name: string;
  price: number;
  quantity: number;
}

interface CartState {
  items: CartItem[];
}

const cartSlice = createSlice({
  name: 'cart',
  initialState: { items: [] } as CartState,
  reducers: {
    addItem(state, action: PayloadAction<Omit<CartItem, 'quantity'>>) {
      const existing = state.items.find(i => i.id === action.payload.id);
      if (existing) {
        existing.quantity += 1;
      } else {
        state.items.push({ ...action.payload, quantity: 1 });
      }
    },
    removeItem(state, action: PayloadAction<string>) {
      state.items = state.items.filter(i => i.id !== action.payload);
    },
    updateQuantity(state, action: PayloadAction<{ id: string; quantity: number }>) {
      const item = state.items.find(i => i.id === action.payload.id);
      if (item) {
        item.quantity = action.payload.quantity;
      }
    },
    clearCart(state) {
      state.items = [];
    },
  },
});

export const { addItem, removeItem, updateQuantity, clearCart } = cartSlice.actions;

export const store = configureStore({
  reducer: {
    cart: cartSlice.reducer,
  },
});

export type RootState = ReturnType<typeof store.getState>;
export type AppDispatch = typeof store.dispatch;

Create typed hooks:

import { useSelector, useDispatch, type TypedUseSelectorHook } from 'react-redux';
import type { RootState, AppDispatch } from './store';

export const useAppDispatch = () => useDispatch<AppDispatch>();
export const useAppSelector: TypedUseSelectorHook<RootState> = useSelector;

Using it in components:

function CartBadge() {
  // Only re-renders when itemCount changes
  const itemCount = useAppSelector(state =>
    state.cart.items.reduce((sum, item) => sum + item.quantity, 0)
  );

  return <span className="badge">{itemCount}</span>;
}

function AddToCartButton({ product }: { product: Product }) {
  const dispatch = useAppDispatch();

  return (
    <button onClick={() => dispatch(addItem(product))}>
      Add to Cart
    </button>
  );
}

Redux strengths: Fine-grained subscriptions (components only re-render when their selected data changes), excellent DevTools, middleware for side effects, large ecosystem.

Redux weaknesses: More boilerplate than alternatives, steeper learning curve, separate package needed.

Zustand

Zustand takes a minimalist approach. No providers, no boilerplate, just a store:

npm install zustand
import { create } from 'zustand';

interface CartItem {
  id: string;
  name: string;
  price: number;
  quantity: number;
}

interface CartStore {
  items: CartItem[];
  addItem: (item: Omit<CartItem, 'quantity'>) => void;
  removeItem: (id: string) => void;
  updateQuantity: (id: string, quantity: number) => void;
  clearCart: () => void;
  total: () => number;
}

export const useCartStore = create<CartStore>((set, get) => ({
  items: [],

  addItem: (item) =>
    set(state => {
      const existing = state.items.find(i => i.id === item.id);
      if (existing) {
        return {
          items: state.items.map(i =>
            i.id === item.id ? { ...i, quantity: i.quantity + 1 } : i
          ),
        };
      }
      return { items: [...state.items, { ...item, quantity: 1 }] };
    }),

  removeItem: (id) =>
    set(state => ({
      items: state.items.filter(i => i.id !== id),
    })),

  updateQuantity: (id, quantity) =>
    set(state => ({
      items: state.items.map(i =>
        i.id === id ? { ...i, quantity } : i
      ),
    })),

  clearCart: () => set({ items: [] }),

  total: () => {
    const { items } = get();
    return items.reduce((sum, i) => sum + i.price * i.quantity, 0);
  },
}));

Using it in components:

function CartBadge() {
  const itemCount = useCartStore(state =>
    state.items.reduce((sum, item) => sum + item.quantity, 0)
  );

  return <span className="badge">{itemCount}</span>;
}

function AddToCartButton({ product }: { product: Product }) {
  const addItem = useCartStore(state => state.addItem);

  return (
    <button onClick={() => addItem(product)}>
      Add to Cart
    </button>
  );
}

Notice there is no Provider wrapper needed. Zustand stores work outside the React tree.

Zustand strengths: Minimal API, no provider needed, fine-grained subscriptions, works outside React, tiny bundle size (about 1KB).

Zustand weaknesses: Smaller ecosystem than Redux, no built-in DevTools middleware (though it has a devtools middleware you can add), less structured for very large apps.

Side-by-Side Comparison

Here is the same feature in each approach. Notice the differences in verbosity:

FeatureContextRedux ToolkitZustand
Setup code~60 lines~50 lines~40 lines
Provider neededYesYesNo
Re-render optimizationManual splittingBuilt-in selectorsBuilt-in selectors
DevToolsReact DevToolsRedux DevToolsOptional middleware
Bundle size0 KB~11 KB~1 KB
Learning curveLowMediumLow
Async actionsCustomcreateAsyncThunkBuilt-in
MiddlewareNoneBuilt-inPlugin system

When to Use Each

Use Context API when:

  • State is simple (theme, locale, auth status)
  • Few components consume the state
  • You want zero dependencies
  • The state does not change frequently

Use Redux Toolkit when:

  • You have complex state with many actions
  • You need middleware for side effects
  • Multiple developers need consistent patterns
  • You want time-travel debugging
  • The app is large with many state slices

Use Zustand when:

  • You want simple global state with good performance
  • You need state accessible outside React components
  • You want minimal bundle size
  • You are building a small to medium app
  • You want quick setup without boilerplate

Mixing Approaches

You do not have to pick just one. Many apps combine them:

// Context for theme and locale (rarely changes, few consumers)
<ThemeProvider>
  <LocaleProvider>
    {/* Zustand for cart, UI state (changes often, many consumers) */}
    <App />
  </LocaleProvider>
</ThemeProvider>

Use Context for dependency injection (passing services, configuration) and a dedicated state library for application state that changes frequently.

Wrapping Up

Context API works well for simple, infrequently-changing state. Redux Toolkit provides structure and tools for complex applications with many developers. Zustand offers the best balance of simplicity and performance for most projects. Start with the simplest option that meets your needs and migrate up only when you hit real limitations. The best state management solution is the one your team can understand and maintain.