Step-by-step
-
1
Know when Context is the right tool
Context is appropriate when:
- State needs to be read by components at different nesting levels (theme, locale, current user, auth token, cart)
- Prop drilling is already painful — you are passing the same prop through 3+ layers that do not use it themselves
Context is not appropriate when:
- State changes frequently (every keystroke, scroll event, animation frame) — see the warning in the last step
- State is local to one subtree — just pass props or lift state
-
2
Create two separate contexts
The most common Context mistake is putting both the state value and the dispatch function in a single context. Any component that consumes it will re-render whenever state changes, even if it only needs to dispatch actions. Split them from the start.
jsximport { createContext } from 'react'; // Consumers of this context re-render when state changes export const CartStateContext = createContext(null); // Consumers of this context NEVER re-render due to state changes // (dispatch is stable — React guarantees it never changes) export const CartDispatchContext = createContext(null); -
3
Define the reducer
A reducer is a pure function:
(state, action) => newState. It should never mutate state directly — always return a new object. Keep it in its own file so it is easy to test.javascript// cart-reducer.js export const initialState = { items: [], total: 0 }; export function cartReducer(state, action) { switch (action.type) { case 'ADD_ITEM': { const exists = state.items.find(i => i.id === action.item.id); const items = exists ? state.items.map(i => i.id === action.item.id ? { ...i, qty: i.qty + 1 } : i ) : [...state.items, { ...action.item, qty: 1 }]; return { items, total: items.reduce((s, i) => s + i.price * i.qty, 0) }; } case 'REMOVE_ITEM': return { ...state, items: state.items.filter(i => i.id !== action.id), total: state.total - (state.items.find(i => i.id === action.id)?.price ?? 0), }; case 'CLEAR': return initialState; default: return state; } } -
4
Build the Provider component
The Provider wraps your app (or the subtree that needs the state) and owns the
useReducercall. It provides the state and dispatch through their separate contexts. This is the only placeuseReduceris called.jsximport { useReducer } from 'react'; import { CartStateContext, CartDispatchContext } from './cart-context'; import { cartReducer, initialState } from './cart-reducer'; export function CartProvider({ children }) { const [state, dispatch] = useReducer(cartReducer, initialState); return ( <CartDispatchContext.Provider value={dispatch}> <CartStateContext.Provider value={state}> {children} </CartStateContext.Provider> </CartDispatchContext.Provider> ); } -
5
Wrap the app with the Provider
Mount the Provider at the highest point in the tree that needs access to this state. Usually that is near the app root, but not necessarily — if only the checkout flow needs the cart, wrap only that subtree.
jsx// main.jsx or App.jsx import { CartProvider } from './cart-provider'; function App() { return ( <CartProvider> <Header /> <main> <ProductList /> <CheckoutFlow /> </main> </CartProvider> ); } -
6
Consume state and dispatch in children
Components that read state consume
CartStateContext. Components that only fire actions consumeCartDispatchContext. An "Add to Cart" button only needs dispatch — it will never re-render because state changed.jsximport { useContext } from 'react'; import { CartStateContext, CartDispatchContext } from './cart-context'; // Read-only: re-renders when cart state changes function CartSummary() { const { items, total } = useContext(CartStateContext); return ( <div> <span>{items.length} items</span> <span>${total.toFixed(2)}</span> </div> ); } // Write-only: never re-renders due to cart state changes function AddToCartButton({ product }) { const dispatch = useContext(CartDispatchContext); return ( <button onClick={() => dispatch({ type: 'ADD_ITEM', item: product })}> Add to cart </button> ); } -
7
Create custom hooks for cleaner API
Exporting the raw contexts forces every consumer to import two files and call
useContextdirectly. Wrap them in named hooks. This also lets you add a guard that throws a useful error if the hook is used outside its Provider.javascriptimport { useContext } from 'react'; import { CartStateContext, CartDispatchContext } from './cart-context'; export function useCartState() { const ctx = useContext(CartStateContext); if (ctx === null) throw new Error('useCartState must be used inside <CartProvider>'); return ctx; } export function useCartDispatch() { const ctx = useContext(CartDispatchContext); if (ctx === null) throw new Error('useCartDispatch must be used inside <CartProvider>'); return ctx; } // Usage anywhere in the tree: // const { items } = useCartState(); // const dispatch = useCartDispatch(); -
8
Know when Context is the wrong choice
Context re-renders every consuming component when its value changes. For state that changes many times per second — a form field on each keystroke, an animation, a mouse position — this causes cascading re-renders that cannot be fixed with
useMemoorReact.memoalone. For high-frequency state, use a dedicated library:- Zustand — minimal, no boilerplate, subscription-based (only subscribed components re-render)
- Jotai — atomic state, fine-grained subscriptions
- Redux Toolkit — worth it at large scale with strict team patterns
Context is the right choice for state that changes rarely: auth, theme, locale, feature flags.
Tips & gotchas
- The split-context pattern (value context vs dispatch context) is the single most impactful Context optimization. Apply it every time.
- `React.memo` wraps a component and prevents re-rendering if its props have not changed — combine it with the split-context pattern for maximum effect.
- Multiple small contexts beat one large context. A `ThemeContext`, `AuthContext`, and `CartContext` are better than a single `AppContext` holding everything.
- You can nest Providers freely. A `CartProvider` inside `AuthProvider` inside `ThemeProvider` is a perfectly valid pattern.
- Do not put functions into context values unless they are stable (wrapped in `useCallback`). A new function reference on every render makes the context value look "changed" and triggers re-renders.
Wrapping up
Context + useReducer is a lightweight, built-in solution for global state that does not require installing anything. The key discipline is splitting state and dispatch into separate contexts, defining a clean reducer, and knowing the boundary — state that changes rarely is a great fit; state that changes constantly is not. For everything beyond that boundary, Zustand or Redux Toolkit are the right next step.