liminfo

React State Management Pattern Comparison (useState/useReducer/Context)

Compare the characteristics of React's built-in state management tools -- useState, useReducer, and Context API -- with practical code examples, and choose the optimal pattern for your project size

React state managementuseState vs useReducerReact Context APIstate management patternsReact hooks patternsuseReducer tutorialglobal state managementReact performance optimization

Problem

You are developing a mid-scale React application (an e-commerce shopping cart). The cart requires complex state logic: adding/removing products, quantity changes, filtering, and total price calculation. Multiple components need access to the cart state (header item count, cart page, checkout page), and prop drilling is becoming severe. Using useState alone makes state update logic overly complex, and before introducing external libraries (Redux, Zustand), you need to determine how far React's built-in tools can take you.

Required Tools

useState

The most basic React Hook. Best suited for simple values (strings, numbers, booleans) or independent state.

useReducer

A React Hook that brings the Redux pattern built-in. Ideal for complex state logic where multiple fields are updated together.

Context API

A mechanism for passing data through the component tree without prop drilling. Used for sharing global state.

useMemo / useCallback

Memoization hooks that prevent unnecessary recalculations and re-renders. Essential for performance optimization with Context.

TypeScript

Provides type safety to validate reducer action types and state shape at compile time.

Solution Steps

1

Start with simple state using useState

Start with the most basic useState. It is optimal for simple counters or toggle states, but when dependencies between states arise, update logic becomes complex. When multiple useStates lead to code that depends on update ordering, consider switching to useReducer.

import { useState } from 'react';

// --- 1. Simple state: useState is optimal ---
function ProductFilter() {
  const [search, setSearch] = useState('');
  const [category, setCategory] = useState('all');
  const [sortBy, setSortBy] = useState<'price' | 'name'>('price');

  return (
    <div>
      <input value={search} onChange={e => setSearch(e.target.value)} />
      <select value={category} onChange={e => setCategory(e.target.value)}>
        <option value="all">All</option>
        <option value="electronics">Electronics</option>
      </select>
    </div>
  );
}

// --- 2. When useState becomes complex ---
function CartWithUseState() {
  const [items, setItems] = useState<CartItem[]>([]);
  const [coupon, setCoupon] = useState<string | null>(null);
  const [shipping, setShipping] = useState<'standard' | 'express'>('standard');

  // Problem: state update logic scattered across the component
  const addItem = (product: Product) => {
    setItems(prev => {
      const existing = prev.find(i => i.id === product.id);
      if (existing) {
        return prev.map(i =>
          i.id === product.id ? { ...i, qty: i.qty + 1 } : i
        );
      }
      return [...prev, { ...product, qty: 1 }];
    });
  };

  const removeItem = (id: string) => {
    setItems(prev => prev.filter(i => i.id !== id));
    // Problem: need to also check coupon validity on item removal
    // -> situation requiring simultaneous multi-state updates
  };

  const total = items.reduce((sum, i) => sum + i.price * i.qty, 0);
  const shippingFee = shipping === 'express' ? 5000 : total >= 50000 ? 0 : 3000;

  return <div>{/* ... */}</div>;
}
2

Consolidate complex state logic with useReducer

useReducer manages multiple related states within a single reducer function. Express intent clearly through action types, and update multiple states simultaneously with a single dispatch. Defining actions as TypeScript discriminated unions ensures compile-time type safety.

import { useReducer } from 'react';

// --- Type definitions ---
interface CartItem {
  id: string;
  name: string;
  price: number;
  qty: number;
}

interface CartState {
  items: CartItem[];
  coupon: string | null;
  discount: number;
  shipping: 'standard' | 'express';
}

// Define action types as discriminated union
type CartAction =
  | { type: 'ADD_ITEM'; payload: Omit<CartItem, 'qty'> }
  | { type: 'REMOVE_ITEM'; payload: { id: string } }
  | { type: 'UPDATE_QTY'; payload: { id: string; qty: number } }
  | { type: 'APPLY_COUPON'; payload: { code: string; discount: number } }
  | { type: 'REMOVE_COUPON' }
  | { type: 'SET_SHIPPING'; payload: 'standard' | 'express' }
  | { type: 'CLEAR_CART' };

const initialState: CartState = {
  items: [],
  coupon: null,
  discount: 0,
  shipping: 'standard',
};

// --- Reducer function ---
function cartReducer(state: CartState, action: CartAction): CartState {
  switch (action.type) {
    case 'ADD_ITEM': {
      const existing = state.items.find(i => i.id === action.payload.id);
      if (existing) {
        return {
          ...state,
          items: state.items.map(i =>
            i.id === action.payload.id ? { ...i, qty: i.qty + 1 } : i
          ),
        };
      }
      return { ...state, items: [...state.items, { ...action.payload, qty: 1 }] };
    }
    case 'REMOVE_ITEM': {
      const newItems = state.items.filter(i => i.id !== action.payload.id);
      const subtotal = newItems.reduce((s, i) => s + i.price * i.qty, 0);
      return {
        ...state,
        items: newItems,
        coupon: subtotal < 30000 ? null : state.coupon,
        discount: subtotal < 30000 ? 0 : state.discount,
      };
    }
    case 'UPDATE_QTY':
      return {
        ...state,
        items: state.items.map(i =>
          i.id === action.payload.id
            ? { ...i, qty: Math.max(1, action.payload.qty) }
            : i
        ),
      };
    case 'APPLY_COUPON':
      return { ...state, coupon: action.payload.code, discount: action.payload.discount };
    case 'REMOVE_COUPON':
      return { ...state, coupon: null, discount: 0 };
    case 'SET_SHIPPING':
      return { ...state, shipping: action.payload };
    case 'CLEAR_CART':
      return initialState;
    default:
      return state;
  }
}
3

Share global state with Context API

With Context API, any component in the tree can access state without prop drilling. Use the Provider pattern to pass both state and dispatch, but always use separate Contexts to prevent unnecessary re-renders.

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

// --- Create Context (separate state and dispatch) ---
const CartStateContext = createContext<CartState | null>(null);
const CartDispatchContext = createContext<React.Dispatch<CartAction> | null>(null);

// --- Provider component ---
export function CartProvider({ children }: { children: ReactNode }) {
  const [state, dispatch] = useReducer(cartReducer, initialState);

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

// --- Custom Hooks (type-safe access) ---
export function useCartState() {
  const context = useContext(CartStateContext);
  if (!context) throw new Error('useCartState must be used within CartProvider');
  return context;
}

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

// --- Usage: Header (displays item count only) ---
function HeaderCartBadge() {
  const { items } = useCartState();
  const totalQty = items.reduce((sum, i) => sum + i.qty, 0);
  return <span className="badge">{totalQty}</span>;
}

// --- Usage: Product page (uses dispatch only) ---
function AddToCartButton({ product }: { product: Product }) {
  const dispatch = useCartDispatch();
  return (
    <button onClick={() => dispatch({ type: 'ADD_ITEM', payload: product })}>
      Add to Cart
    </button>
  );
}

// --- App with Provider wrapper ---
function App() {
  return (
    <CartProvider>
      <Header />
      <ProductList />
      <CartPage />
    </CartProvider>
  );
}
4

Optimize performance with useMemo/useCallback

When using Context, all consumers re-render when the Provider's value changes. Memoize derived state (total price, filtered lists) with useMemo and event handlers with useCallback to prevent unnecessary re-renders. Combined with React.memo, child component re-renders can also be minimized.

import { useMemo, useCallback, memo } from 'react';

// --- Derived state calculation (useMemo) ---
function CartSummary() {
  const state = useCartState();

  const subtotal = useMemo(
    () => state.items.reduce((sum, i) => sum + i.price * i.qty, 0),
    [state.items]
  );

  const shippingFee = useMemo(() => {
    if (state.shipping === 'express') return 5000;
    return subtotal >= 50000 ? 0 : 3000;
  }, [state.shipping, subtotal]);

  const total = useMemo(
    () => subtotal - state.discount + shippingFee,
    [subtotal, state.discount, shippingFee]
  );

  return (
    <div>
      <p>Subtotal: ${subtotal.toLocaleString()}</p>
      <p>Discount: -${state.discount.toLocaleString()}</p>
      <p>Shipping: ${shippingFee.toLocaleString()}</p>
      <p>Total: ${total.toLocaleString()}</p>
    </div>
  );
}

// --- Action creators (useCallback) ---
function useCartActions() {
  const dispatch = useCartDispatch();

  const addItem = useCallback(
    (product: Omit<CartItem, 'qty'>) =>
      dispatch({ type: 'ADD_ITEM', payload: product }),
    [dispatch]
  );

  const removeItem = useCallback(
    (id: string) => dispatch({ type: 'REMOVE_ITEM', payload: { id } }),
    [dispatch]
  );

  const updateQty = useCallback(
    (id: string, qty: number) =>
      dispatch({ type: 'UPDATE_QTY', payload: { id, qty } }),
    [dispatch]
  );

  return { addItem, removeItem, updateQty };
}

// --- Prevent unnecessary re-renders with React.memo ---
const CartItemRow = memo(function CartItemRow({
  item,
  onRemove,
  onUpdateQty,
}: {
  item: CartItem;
  onRemove: (id: string) => void;
  onUpdateQty: (id: string, qty: number) => void;
}) {
  return (
    <div>
      <span>{item.name}</span>
      <input
        type="number"
        value={item.qty}
        onChange={e => onUpdateQty(item.id, Number(e.target.value))}
      />
      <button onClick={() => onRemove(item.id)}>Remove</button>
    </div>
  );
});
5

Pattern selection guide and combination strategy

useState, useReducer, and Context are not replacements for each other but tools meant to be combined. Use useState for local UI state (modal open, input values), useReducer for complex domain logic, and add Context when global sharing is needed. Overusing Context causes performance issues, so keep frequently-changing state local whenever possible.

// === Pattern Selection Decision Tree ===

// 1. useState: Simple and independent state
//    - Form inputs, toggles, counters
//    - Used in a single component only
function SimpleForm() {
  const [name, setName] = useState('');
  const [email, setEmail] = useState('');
  const [isOpen, setIsOpen] = useState(false);
  return <div>{/* ... */}</div>;
}

// 2. useReducer: Complex or related state
//    - Multiple fields change simultaneously
//    - Clear state transitions
//    - Business logic requiring testing
function ComplexForm() {
  const [formState, formDispatch] = useReducer(formReducer, {
    values: {},
    errors: {},
    touched: {},
    isSubmitting: false,
  });
  return <div>{/* ... */}</div>;
}

// 3. Context + useReducer: Global sharing needed
//    - Auth state, theme, shopping cart
//    - Accessed from multiple pages/components

// 4. Combination pattern: Right tool for each level
function App() {
  return (
    // Global: Context (auth, theme)
    <AuthProvider>
      <ThemeProvider>
        {/* Domain: Context + useReducer (cart) */}
        <CartProvider>
          <Layout>
            {/* Local: useState (UI state) */}
            <ProductPage />
          </Layout>
        </CartProvider>
      </ThemeProvider>
    </AuthProvider>
  );
}

// 5. When to switch to external libraries
// - Context re-render performance issues measured
// - Server state (API caching) needed -> React Query
// - Very complex global state -> Zustand, Jotai
// - Time-travel debugging needed -> Redux Toolkit

Core Code

The core pattern: manage complex state logic with useReducer, share globally via Context, and separate State/Dispatch Contexts to optimize re-renders.

// === Core: useReducer + Context Combination Pattern ===
import { createContext, useContext, useReducer, type ReactNode } from 'react';

// 1. Type definitions
interface State { items: CartItem[]; coupon: string | null; discount: number; }
type Action =
  | { type: 'ADD_ITEM'; payload: Omit<CartItem, 'qty'> }
  | { type: 'REMOVE_ITEM'; payload: { id: string } }
  | { type: 'CLEAR_CART' };

// 2. Reducer (pure function -> easy to test)
function reducer(state: State, action: Action): State {
  switch (action.type) {
    case 'ADD_ITEM': { /* ... */ }
    case 'REMOVE_ITEM': { /* ... */ }
    case 'CLEAR_CART': return { items: [], coupon: null, discount: 0 };
    default: return state;
  }
}

// 3. Separate Contexts (re-render optimization)
const StateCtx = createContext<State | null>(null);
const DispatchCtx = createContext<React.Dispatch<Action> | null>(null);

// 4. Provider
export function CartProvider({ children }: { children: ReactNode }) {
  const [state, dispatch] = useReducer(reducer, { items: [], coupon: null, discount: 0 });
  return (
    <StateCtx.Provider value={state}>
      <DispatchCtx.Provider value={dispatch}>{children}</DispatchCtx.Provider>
    </StateCtx.Provider>
  );
}

// 5. Custom Hooks
export const useCartState = () => {
  const ctx = useContext(StateCtx);
  if (!ctx) throw new Error('CartProvider required');
  return ctx;
};
export const useCartDispatch = () => {
  const ctx = useContext(DispatchCtx);
  if (!ctx) throw new Error('CartProvider required');
  return ctx;
};

Common Mistakes

Passing state and dispatch as a single object in Context causing all consumers to re-render

Separate StateContext and DispatchContext. Components that only use dispatch (e.g., AddToCart button) will not re-render on state changes.

Defining useReducer action types as plain strings instead of string literals, losing type safety

Define action types using TypeScript discriminated unions. Writing { type: 'ADD_ITEM'; payload: ... } | { type: 'REMOVE_ITEM'; payload: ... } enables automatic type inference in switch statements.

Putting frequently-changing state (mouse position, timers) in Context causing full tree re-renders

Keep high-frequency state local (useState) whenever possible. If global sharing is absolutely needed, use a library with selector-based subscriptions like Zustand or Jotai instead of Context.

Related liminfo Services