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
Problem
Required Tools
The most basic React Hook. Best suited for simple values (strings, numbers, booleans) or independent state.
A React Hook that brings the Redux pattern built-in. Ideal for complex state logic where multiple fields are updated together.
A mechanism for passing data through the component tree without prop drilling. Used for sharing global state.
Memoization hooks that prevent unnecessary recalculations and re-renders. Essential for performance optimization with Context.
Provides type safety to validate reducer action types and state shape at compile time.
Solution Steps
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>;
}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;
}
}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>
);
}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>
);
});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 ToolkitCore 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.