useContext useReducer—Ghost Cart: Mutation Skips Re-render
After clearing the cart, total price lingered because reducer's sort() mutated the array.
20+ years shipping production JavaScript and front-end systems at scale. Everything here is grounded in real deployments.
- ✓Solid grasp of fundamentals
- ✓Comfortable reading code examples
- ✓Basic production concepts
- useContext creates a subscription channel from Provider to any descendant component, eliminating prop drilling.
- useReducer centralizes state transition logic into a pure function (the reducer) for complex flows.
- Separating state and dispatch contexts prevents re-renders: dispatch-only components skip state updates.
- Performance: each context update triggers re-render in all consumers — keep values stable or split contexts.
- Production insight: a single giant AppContext causes cascading re-renders; split by domain (auth, cart, UI).
- Biggest mistake: mutating state inside the reducer instead of returning new objects — React won't re-render.
This article tackles a specific React anti-pattern: mutating state inside a useReducer dispatch while relying on useContext to propagate changes. The core problem is that React's re-render optimization skips components when the context value reference doesn't change—but if you mutate the existing state object inside your reducer, the reference stays the same, and consumers never re-render.
This isn't a bug in React; it's a misunderstanding of how context identity triggers updates. The article walks through why useContext + useReducer is not a state manager (it lacks middleware, devtools, and selective subscriptions), when prop drilling is actually the right call (for deeply nested, rarely-changing config), and how context's provider/consumer data flow works under the hood.
You'll get a step-by-step migration from useState to useReducer for complex state logic, plus a comparison table showing when to reach for Context, Redux, Zustand, or Jotai. Real-world example: a ghost cart where items disappear because dispatch({ type: 'ADD_ITEM', payload: item }) mutates state.items.push(item) instead of returning a new array—no re-render, no UI update, silent data loss.
Imagine your school has a PA system. Instead of a teacher running to every classroom to announce lunch is ready, one announcement goes out and every room hears it instantly. That's useContext — shared information without passing notes room to room. Now imagine the school office has a strict rule book: if a student is absent, a specific form gets filled out; if there's a fire drill, a specific procedure runs. That's useReducer — predictable, rule-based responses to events. Together, they're your school's entire communication and decision-making system, built right into React.
| Chrome | Firefox | Safari | Edge |
|---|---|---|---|
| ✓ | ✓ | ✓ | ✓ |
React’s Context API and useReducer are often cargo-culted into a makeshift state manager, but that misconception misses the point. UseContext solves prop drilling, not global state performance. UseReducer simplifies complex state logic when useState starts spawning bugs. This article breaks down exactly what each tool does, where they fail, and how to combine them safely—without burning your app to the ground.
Why useContext + useReducer Is Not a State Manager
useContext + useReducer is a React pattern that combines context for dependency injection with a reducer for state transitions. The core mechanic: useReducer returns a dispatch function that you pass through context, allowing deeply nested components to trigger state updates without prop drilling. But here's the catch—React's bailout logic means that if a reducer returns the same state reference (e.g., mutating an array or object in place), the context value doesn't change, and no subscriber re-renders. This is not a bug; it's React's referential equality check for performance. In practice, you must always return a new object or array from the reducer. The pattern works well for medium-complexity state that doesn't need middleware, devtools, or cross-component caching. It fails when you treat it like Redux—it lacks batching guarantees, selector memoization, and external store subscriptions. Use it for form state, wizard steps, or UI toggles; avoid it for server cache or real-time data.
Why Prop Drilling Is a Real Problem (and What useContext Actually Solves)
Prop drilling isn't just annoying — it's a maintenance hazard. When a component in the middle of your tree needs to pass a prop purely so its child's child can use it, that middle component becomes coupled to data it doesn't care about. Rename a prop, change its shape, or remove it, and you're hunting down every intermediary component that passed it along.
useContext solves this by creating a named 'channel' in your app. A Provider component sits near the top of your tree and broadcasts a value. Any component anywhere below it can tune into that channel directly using the useContext hook. The components in between don't need to know it exists.
This is perfect for genuinely global data: authentication state, user preferences, UI themes, or locale settings. The key word is global. useContext isn't a replacement for all props — if two sibling components share local state, lifting state up is still the right move. Context is for data that many, structurally distant components need simultaneously.
Think of createContext as setting up the PA system, the Provider as turning the microphone on, and useContext as each classroom having a speaker.
import React, { createContext, useContext, useState } from 'react'; // Step 1: Create the context object. // The argument to createContext is the DEFAULT value — // used only when a component reads context WITHOUT a Provider above it. const ThemeContext = createContext('light'); // Step 2: Build a custom Provider component. // This keeps all theme-related state and logic in one place. function ThemeProvider({ children }) { const [theme, setTheme] = useState('light'); // We pass both the value AND the updater so consumers can read and write const contextValue = { theme, toggleTheme: () => setTheme(prev => prev === 'light' ? 'dark' : 'light'), }; return ( <ThemeContext.Provider value={contextValue}> {children} </ThemeContext.Provider> ); } // Step 3: Build a custom hook to consume this context. // This pattern lets you add error-checking in one place. function useTheme() { const context = useContext(ThemeContext); if (!context) { // This fires if someone uses useTheme() outside of ThemeProvider throw new Error('useTheme must be used inside a ThemeProvider'); } return context; } // --- Consumer Components --- // Notice: NEITHER of these receives any props from their parent. // They reach directly into the context channel. function Header() { const { theme } = useTheme(); // reads theme, doesn't need toggleTheme return ( <header style={{ background: theme === 'dark' ? '#1a1a1a' : '#ffffff', color: theme === 'dark' ? '#fff' : '#000', padding: '1rem' }}> <h1>My App — Current theme: {theme}</h1> </header> ); } function SettingsPanel() { const { theme, toggleTheme } = useTheme(); // reads and writes return ( <div style={{ padding: '1rem' }}> <p>Theme setting: <strong>{theme}</strong></p> <button onClick={toggleTheme}>Toggle Theme</button> </div> ); } // Step 4: Wrap your app (or relevant subtree) in the Provider. // Header and SettingsPanel can be nested ANY depth and still work. export default function App() { return ( <ThemeProvider> <Header /> <main> <SettingsPanel /> </main> </ThemeProvider> ); }
Provider/Consumer Data Flow: How Context Reaches Every Component
Visualising the Provider/Consumer relationship helps you debug subscription issues and plan your context architecture. The diagram below shows a typical setup where a ThemeProvider wraps the entire app tree, and two deeply nested consumers directly access the theme without any props passing through intermediate components.
The key insight: context flows downward from the Provider, but consumers can be at any depth. Each consumer subscribes individually — React uses the component tree to find the nearest matching Provider ancestor. If no Provider exists, the default value passed to createContext is used (or undefined if not provided).
useReducer: When useState Gets Too Complicated to Trust
useState is perfect for simple, independent values. But the moment your state transitions get conditional — 'if the cart has this item, increment its quantity; otherwise, add it; but if quantity hits zero, remove it entirely' — useState starts to crack. You end up with sprawling logic scattered across event handlers, and it becomes hard to trace exactly how your state got into a particular shape.
useReducer pulls all that logic into one pure function called the reducer. A reducer takes the current state and an action object, and returns the new state. That's it. No side effects, no API calls, no DOM mutations — just 'given THIS state and THIS action, HERE is the next state.'
This pattern comes directly from the Flux/Redux world, but useReducer bakes it into React with no extra libraries. The mental model is a vending machine: you press a button (dispatch an action), the machine's internal logic (the reducer) decides what happens, and you get a result. You don't reach inside the machine — you use the defined interface.
This makes your state transitions auditable. You can log every action, write unit tests against your reducer in complete isolation from React, and reason about state changes without understanding the full component tree.
// The reducer lives OUTSIDE the component — it's a pure function. // This means you can import it into a test file and test it with zero React setup. // Define action types as constants to avoid typo bugs (e.g. 'ADD_ITME' instead of 'ADD_ITEM') export const CART_ACTIONS = { ADD_ITEM: 'ADD_ITEM', REMOVE_ITEM: 'REMOVE_ITEM', INCREMENT_QTY: 'INCREMENT_QTY', DECREMENT_QTY: 'DECREMENT_QTY', CLEAR_CART: 'CLEAR_CART', }; // The initial state shape — defined once, reused everywhere export const initialCartState = { items: [], // Array of { id, name, price, quantity } totalItems: 0, totalPrice: 0, }; // Helper: recalculate totals whenever items change function calculateTotals(items) { return { totalItems: items.reduce((sum, item) => sum + item.quantity, 0), totalPrice: items.reduce((sum, item) => sum + item.price * item.quantity, 0), }; } // THE REDUCER — the single source of truth for how cart state changes export function cartReducer(state, action) { switch (action.type) { case CART_ACTIONS.ADD_ITEM: { const existingItem = state.items.find(item => item.id === action.payload.id); let updatedItems; if (existingItem) { // Item already in cart — increment quantity instead of duplicating updatedItems = state.items.map(item => item.id === action.payload.id ? { ...item, quantity: item.quantity + 1 } : item ); } else { // New item — add it with quantity of 1 updatedItems = [...state.items, { ...action.payload, quantity: 1 }]; } return { ...state, items: updatedItems, ...calculateTotals(updatedItems), // spread in recalculated totals }; } case CART_ACTIONS.REMOVE_ITEM: { const filteredItems = state.items.filter(item => item.id !== action.payload.id); return { ...state, items: filteredItems, ...calculateTotals(filteredItems), }; } case CART_ACTIONS.DECREMENT_QTY: { // If quantity would drop to 0, remove the item entirely const decrementedItems = state.items .map(item => item.id === action.payload.id ? { ...item, quantity: item.quantity - 1 } : item ) .filter(item => item.quantity > 0); // auto-remove zero-quantity items return { ...state, items: decrementedItems, ...calculateTotals(decrementedItems), }; } case CART_ACTIONS.CLEAR_CART: return initialCartState; // reset to the original empty state default: // Returning current state on unknown actions prevents silent failures return state; } }
Date.now(), no Math.random().Migration Guide: From useState to useReducer (Step by Step)
Moving from a local useState that has grown unwieldy to a clean useReducer pattern is straightforward. Follow these steps and you'll have a testable, centralized state logic in under an hour.
Step 1: Identify the state — Look for a useState call that manages an object or an array with multiple related fields, especially if you have complex update logic in event handlers.
Step 2: Define action types — Create an object with string constants for every way state can change (e.g., ADD_ITEM, REMOVE_ITEM, CLEAR). This prevents typos and makes the possible transitions easy to review.
Step 3: Extract the reducer function — Write a pure function that takes (state, action) and returns new state. Use a switch on action.type. Keep it in its own file for testability.
Step 4: Replace useState with useReducer — Swap const [state, setState] = useState(initial) to const [state, dispatch] = useReducer(reducer, initialState).
Step 5: Update event handlers — Instead of calling something like setState(prev => ...), each handler now calls dispatch({ type: 'ACTION_NAME', payload: ... }).
Optional Step: Make it context-ready — If multiple components need the same state, wrap the useReducer in a Provider and expose state + dispatch via contexts as shown earlier.
// --- BEFORE: useState with complex logic scattered in handlers --- function ShoppingCartBefore() { const [cart, setCart] = useState({ items: [], total: 0 }); const addItem = (item) => { setCart(prev => { const existing = prev.items.find(i => i.id === item.id); let newItems; if (existing) { newItems = prev.items.map(i => i.id === item.id ? { ...i, qty: i.qty + 1 } : i ); } else { newItems = [...prev.items, { ...item, qty: 1 }]; } const total = newItems.reduce((sum, i) => sum + i.price * i.qty, 0); return { items: newItems, total }; }); }; // removeItem, clearCart etc. also inline — hard to test, easy to bug } // --- AFTER: useReducer with centralized reducer --- const cartReducer = (state, action) => { switch (action.type) { case 'ADD_ITEM': { const existing = state.items.find(i => i.id === action.payload.id); const newItems = existing ? state.items.map(i => i.id === existing.id ? { ...i, qty: i.qty + 1 } : i) : [...state.items, { ...action.payload, qty: 1 }]; return { ...state, items: newItems, total: newItems.reduce((s, i) => s + i.price * i.qty, 0) }; } // other cases ... default: return state; } }; function ShoppingCartAfter() { const [cart, dispatch] = useReducer(cartReducer, { items: [], total: 0 }); const addItem = (item) => dispatch({ type: 'ADD_ITEM', payload: item }); // other handlers become one-liners } // The reducer can now be exported and unit-tested in isolation!
no-param-reassign ESLint rule (from eslint-plugin-import) and set it to error inside reducer files. This prevents accidental .push(), .pop(), .sort() on arguments. The rule react/no-direct-mutation-state catches mutations in useState calls but doesn't cover useReducer; the no-param-reassign rule fills that gap.setState calls after switching to dispatch, creating two competing sources of truth. During migration, remove the old useState entirely before introducing useReducer to avoid confusing bugs.State Management Comparison: Which Tool for Which Job?
Choosing the right state management approach is a critical architectural decision. The table below compares the five most popular patterns in React today across dimensions that matter in production: setup complexity, scope, performance, testability, and team scalability.
| Aspect | useState (local) | useReducer (local) | useContext + useReducer | Zustand | Redux Toolkit |
|---|---|---|---|---|---|
| Setup complexity | Zero — built in | Zero — built in | Low — two hooks, one context file | Low — create store, no Provider needed | Medium — store, slices, Provider |
| State scope | Single component | Single component | Component subtree (feature-level) | Global but can scope | Entire application |
| Logic complexity | Simple values only | Complex transitions, multiple cases | Complex transitions, multiple cases | Workflow-based, flexible | Complex async, middleware, side effects |
| Performance risk | None | None | Re-renders all consumers on context change | Very low — no context re-render cascade | Minimal — optimised selectors built in |
| DevTools support | React DevTools only | React DevTools only | React DevTools only | Zustand DevTools (basic) | Redux DevTools with time travel |
| Testability of logic | Logic is in component | Reducer is pure isolated function | Reducer is pure isolated function | Store logic is isolated | Slices are pure isolated functions |
| Best for | Modal open/close, form fields | Single-component complex state | Cart, auth session, multi-step forms | Mid-size apps, frequent updates | Large teams, complex async workflows |
| Bundle size added | 0 bytes | 0 bytes | 0 bytes | ~2KB minified | ~12KB (RTK minified) |
Key takeaway: No single tool is the best. The decision should be based on the specific pain points of your app. For teams starting out, go with useState → useContext+useReducer — you can always graduate to Zustand or Redux Toolkit later without rewriting everything.
useStore hook that directly subscribes to a store with a selector. Only components that select the changed slice re-render — no cascading. This is why Zustand is often recommended for high-frequency updates that would be problematic in plain useContext.Combining useContext + useReducer: Building a Real Shopping Cart
This is where the two hooks become genuinely powerful. useReducer manages the HOW of state changes — all that complex cart logic lives in one pure, testable function. useContext handles the WHERE — making that state and the dispatch function available to any component without prop drilling.
The pattern is always the same: create a context, build a Provider that runs useReducer internally, and expose both the state and dispatch through that context. Consumer components get access to exactly what they need.
One critical detail: expose dispatch directly rather than wrapping every possible action in a separate callback function. Some tutorials create addToCart, removeFromCart, clearCart as individual functions in context — but this means updating your Provider every time you add a new action type. Exposing dispatch directly means consumers can send any action the reducer understands, and your Provider never needs to change.
This is the architecture pattern you'll see in production codebases. It separates concerns cleanly: the reducer owns business logic, the Provider owns state lifecycle, and consumer components own UI rendering.
import React, { createContext, useContext, useReducer } from 'react'; import { cartReducer, initialCartState, CART_ACTIONS } from './cartReducer'; // --- CONTEXT SETUP --- const CartStateContext = createContext(null); // holds the state const CartDispatchContext = createContext(null); // holds the dispatch function // Separating state and dispatch contexts is a performance optimisation: // A component that only dispatches (like a button) won't re-render when state changes. function CartProvider({ children }) { const [cartState, dispatch] = useReducer(cartReducer, initialCartState); return ( <CartStateContext.Provider value={cartState}> <CartDispatchContext.Provider value={dispatch}> {children} </CartDispatchContext.Provider> </CartStateContext.Provider> ); } // Custom hooks — clean API, built-in error checking function useCartState() { const context = useContext(CartStateContext); if (context === null) throw new Error('useCartState must be used inside CartProvider'); return context; } function useCartDispatch() { const context = useContext(CartDispatchContext); if (context === null) throw new Error('useCartDispatch must be used inside CartProvider'); return context; } // --- PRODUCT CATALOGUE --- const PRODUCTS = [ { id: 101, name: 'Mechanical Keyboard', price: 129.99 }, { id: 102, name: 'USB-C Hub', price: 39.99 }, { id: 103, name: 'Webcam HD', price: 79.99 }, ]; function ProductCard({ product }) { // Only subscribes to dispatch — won't re-render on cart state changes const dispatch = useCartDispatch(); function handleAddToCart() { dispatch({ type: CART_ACTIONS.ADD_ITEM, payload: product, // pass the full product object as the payload }); } return ( <div style={{ border: '1px solid #ccc', padding: '1rem', marginBottom: '0.5rem', borderRadius: '8px' }}> <strong>{product.name}</strong> <span style={{ marginLeft: '1rem', color: '#555' }}>${product.price.toFixed(2)}</span> <button onClick={handleAddToCart} style={{ marginLeft: '1rem' }}>Add to Cart</button> </div> ); } // --- CART DISPLAY --- function CartSummary() { // Subscribes to cart STATE — re-renders when items change const { items, totalItems, totalPrice } = useCartState(); const dispatch = useCartDispatch(); if (items.length === 0) { return <p style={{ color: '#888' }}>Your cart is empty.</p>; } return ( <div style={{ background: '#f9f9f9', padding: '1rem', borderRadius: '8px' }}> <h3>Cart ({totalItems} items)</h3> {items.map(item => ( <div key={item.id} style={{ display: 'flex', alignItems: 'center', gap: '0.75rem', marginBottom: '0.5rem' }}> <span>{item.name}</span> <span style={{ color: '#555' }}>${item.price.toFixed(2)} × {item.quantity}</span> {/* Decrement — auto-removes item if quantity hits 0 (handled in reducer) */} <button onClick={() => dispatch({ type: CART_ACTIONS.DECREMENT_QTY, payload: { id: item.id } })}>−</button> <button onClick={() => dispatch({ type: CART_ACTIONS.ADD_ITEM, payload: item })}>+</button> <button onClick={() => dispatch({ type: CART_ACTIONS.REMOVE_ITEM, payload: { id: item.id } })} style={{ color: 'red' }}>Remove</button> </div> ))} <hr /> <strong>Total: ${totalPrice.toFixed(2)}</strong> <button onClick={() => dispatch({ type: CART_ACTIONS.CLEAR_CART })} style={{ marginLeft: '1rem', background: '#e55', color: '#fff', border: 'none', padding: '0.3rem 0.75rem', borderRadius: '4px' }}> Clear Cart </button> </div> ); } // --- APP ENTRY POINT --- export default function App() { return ( <CartProvider> {/* All cart state + dispatch live here */} <h1>Dev Gear Shop</h1> <h2>Products</h2> {PRODUCTS.map(product => ( <ProductCard key={product.id} product={product} /> ))} <h2 style={{ marginTop: '2rem' }}>Your Cart</h2> <CartSummary /> </CartProvider> ); }
When NOT to Use useContext Alone (Performance Pitfalls)
While useContext itself is a simple hook, using it incorrectly can tank your app's performance. Here are the key scenarios where you should avoid useContext — or restructure how you use it.
1. Frequently Changing Values — If you put a value in context that updates multiple times per second (mouse position, animation frame, live search results), every subscribed component will re-render on every change. This can cause jank, especially on lower-end devices. Keep such values in local useState or use refs.
2. Object/Array Values Without Memoisation — If your provider creates a new object or array on every render (e.g., value={{ user, setUser }}), all consumers re-render even if the data didn't logically change. Always wrap the context value in useMemo if it's an object or array.
3. Deeply Nested Providers — Having many nested providers (e.g., ThemeProvider > AuthProvider > CartProvider > UIConfigProvider) can slow down tree reconciliation. React walks the tree to find context values, and deeply nesting slows this walk. Flatten where possible.
4. Misuse as Global State for Business Logic — Using useContext for state that should live in a client-side database (like cached API responses) leads to performance issues because context doesn't provide memoisation or efficient selectors like Redux or Zustand do.
5. Testing Complexity — Components that rely on context become harder to unit test because you must wrap them in the appropriate provider(s) even for simple renders. Often, prop drilling is simpler for depth-1 or depth-2 data.
// 🚫 ANTI-PATTERN: Unmemoised context value causes all consumers to re-render function BadProvider({ children }) { const [user, setUser] = useState(null); // Every time BadProvider re-renders (e.g., due to parent), this creates a NEW object // Even if user hasn't changed, all consumers see a new reference → re-render return ( <UserContext.Provider value={{ user, setUser }}> {children} </UserContext.Provider> ); } // ✅ FIX: memoise the context value function GoodProvider({ children }) { const [user, setUser] = useState(null); const value = useMemo(() => ({ user, setUser }), [user]); // only new object when user changes return ( <UserContext.Provider value={value}> {children} </UserContext.Provider> ); }
useMemo for context values that are objects or arrays, and consider using the split-context pattern to allow components to subscribe only to the parts they need.When NOT to Use This Pattern (and What to Use Instead)
Context + useReducer is genuinely powerful, but it's not the right tool for every job. Knowing when NOT to use it is what separates good engineers from great ones.
Context re-renders every subscriber whenever its value changes. If you put your entire application state in a single context and update it frequently — say, a real-time feed that changes every second — every component that reads that context re-renders on every update. This is why the split-context pattern from the previous section matters, and why very high-frequency state (animation frames, mouse position, live typing) should stay in local useState, not context.
For large-scale apps with complex async flows, multiple developers, time-travel debugging needs, or middleware requirements, Redux Toolkit or Zustand are better choices. They add structure that a growing team needs, and tools like Redux DevTools are genuinely irreplaceable when debugging complex state bugs.
The right mental model: useContext + useReducer is a great fit for feature-scoped state (an entire checkout flow, a wizard form, a dashboard filter system) shared across a component subtree. It's less suited for truly app-wide state that dozens of components all need simultaneously at high update frequency.
// DECISION GUIDE — no need to run this, it's a reference map // ✅ USE LOCAL useState WHEN: // - Only one or two components need this state // - State is simple (a boolean, a string, a single number) // - State doesn't need to survive navigation away from a component const [isMenuOpen, setIsMenuOpen] = useState(false); // ✅ USE useContext + useReducer WHEN: // - Multiple components at different depths need the same state // - State has complex transition logic (like our cart) // - You want testable state logic without a full Redux setup // - The feature is self-contained (cart, auth session, wizard) // ✅ USE REDUX TOOLKIT WHEN: // - Multiple teams work on the same codebase // - You need middleware (thunks for async, sagas, etc.) // - You want time-travel debugging and action history // - You have 10+ slices of global state that all interact // ✅ USE ZUSTAND / JOTAI WHEN: // - You want minimal boilerplate with shared global state // - You need to share state between components NOT in the same tree // - Performance of frequent updates is critical // (Zustand doesn't use React context internally — no re-render cascade) // THE RULE OF THUMB: // Reach for the simplest tool that solves your actual problem. // useState → useContext+useReducer → Zustand → Redux Toolkit // Move right only when the tool on the left genuinely stops working for you.
Testing the Pattern in Isolation: Reducer Unit Tests and Context Integration Tests
One of the biggest advantages of useReducer is that the reducer is a pure function — you can test it without mounting any React components. That makes your state logic fast to verify and resistant to UI regressions. For the provider and consumer behaviour, integration tests with React Testing Library fill the gap.
Every reducer should be tested on its own: feed it an initial state and an action, assert the returned state matches expectations. This catches edge cases like zero-quantity items being removed, totals recalculating correctly, and unknown actions returning current state.
For context integration, render the provider with a test consumer that reads state and dispatches actions. Verify the consumer re-renders with the correct values after dispatch. This tests that the wiring between context, dispatch, and the component is correct.
Key pitfalls: avoid testing internal implementation details (e.g., checking that a specific reducer case was called). Instead, test the visible outcome — what does the component render after a sequence of actions?
import { cartReducer, initialCartState, CART_ACTIONS } from './cartReducer'; // Unit test: pure function, no React dependency describe('cartReducer', () => { test('ADD_ITEM adds a new item with quantity 1', () => { const newState = cartReducer(initialCartState, { type: CART_ACTIONS.ADD_ITEM, payload: { id: 1, name: 'Test', price: 10 }, }); expect(newState.items).toHaveLength(1); expect(newState.items[0].quantity).toBe(1); expect(newState.totalItems).toBe(1); expect(newState.totalPrice).toBe(10); }); test('ADD_ITEM on existing item increments quantity', () => { const stateWithItem = cartReducer(initialCartState, { type: CART_ACTIONS.ADD_ITEM, payload: { id: 1, name: 'Test', price: 10 }, }); const nextState = cartReducer(stateWithItem, { type: CART_ACTIONS.ADD_ITEM, payload: { id: 1, name: 'Test', price: 10 }, }); expect(nextState.items).toHaveLength(1); expect(nextState.items[0].quantity).toBe(2); expect(nextState.totalPrice).toBe(20); }); test('DECREMENT_QTY removes item when quantity hits 0', () => { const stateWithOneItem = cartReducer(initialCartState, { type: CART_ACTIONS.ADD_ITEM, payload: { id: 1, name: 'Test', price: 10 }, }); const afterDecrement = cartReducer(stateWithOneItem, { type: CART_ACTIONS.DECREMENT_QTY, payload: { id: 1 }, }); expect(afterDecrement.items).toHaveLength(0); expect(afterDecrement.totalItems).toBe(0); }); test('CLEAR_CART resets to initial state', () => { const stateWithItem = cartReducer(initialCartState, { type: CART_ACTIONS.ADD_ITEM, payload: { id: 1, name: 'Test', price: 10 }, }); const clearedState = cartReducer(stateWithItem, { type: CART_ACTIONS.CLEAR_CART, }); expect(clearedState).toEqual(initialCartState); }); test('unknown action returns current state', () => { const state = { items: [], totalItems: 0, totalPrice: 0 }; const result = cartReducer(state, { type: 'UNKNOWN' }); expect(result).toBe(state); // same reference — not mutated }); }); // Integration test (using React Testing Library) // See CartProvider.test.jsx
How Context + useReducer Bypasses the Prop Drilling Tax Without a Library
You've got a modal that opens from a button three levels deep. Or a theme toggle that every component needs to read. Prop drilling means threading a callback through every intermediate component that doesn't care about it. That's noise. That's maintenance debt.
useContext gives any nested component direct access to a value without passing it through every parent. Combine it with useReducer and you're not just reading state — you're dispatching actions from anywhere in the tree. The component that needs to open a modal calls dispatch({ type: 'OPEN_MODAL' }). The component three levels up listens for the state change. No libraries, no new dependencies, no ceremony.
This isn't about replacing Redux. It's about solving a specific problem: local state that needs to live above the component that uses it, without polluting every intermediate component with props it never touches.
// io.thecodeforge — javascript tutorial import React, { createContext, useContext, useReducer } from 'react'; const ModalContext = createContext(null); const ModalDispatch = createContext(null); function modalReducer(state, action) { switch (action.type) { case 'OPEN_MODAL': return { ...state, isOpen: true, modalType: action.payload }; case 'CLOSE_MODAL': return { ...state, isOpen: false, modalType: null }; default: throw new Error(`Unknown action: ${action.type}`); } } export function ModalProvider({ children }) { const [state, dispatch] = useReducer(modalReducer, { isOpen: false, modalType: null }); return ( <ModalContext.Provider value={state}> <ModalDispatch.Provider value={dispatch}> {children} </ModalDispatch.Provider> </ModalContext.Provider> ); } export function useModal() { return useContext(ModalContext); } export function useModalDispatch() { return useContext(ModalDispatch); }
The Reducer Pattern That Scales Your Logic Without Scaling Your Bugs
A complex form with interdependent fields. A multi-step checkout with undo. A real-time dashboard with optimistic updates. useState in these scenarios turns into a swamp of race conditions, stale closures, and duplicated logic. You've been there. You've debugged it at 2 AM.
useReducer forces you to define every possible state transition as a pure function. That's the point. No side effects, no hidden state mutations. You dispatch an action, the reducer returns the next state. Predictable. Testable. You can unit test the reducer in isolation without mounting a single component.
The win here isn't just fewer bugs. It's that the logic lives in one place. Need to change how a field validation works? You edit the reducer. Need to add a new action? You add a case. The component just reads state and dispatches events. Separation of concerns with a knife edge.
// io.thecodeforge — javascript tutorial const initialState = { step: 'cart', shipping: null, payment: null, error: null, }; function checkoutReducer(state, action) { switch (action.type) { case 'SET_SHIPPING': // Validates before mutate — no surprises if (!action.payload.zipCode) return { ...state, error: 'Missing zip' }; return { ...state, shipping: action.payload, step: 'payment', error: null }; case 'SET_PAYMENT': return { ...state, payment: action.payload, step: 'review' }; case 'SUBMIT_ORDER': // Optimistic UI update — revert on failure return { ...state, step: 'submitting' }; case 'ORDER_FAILED': return { ...state, step: 'review', error: action.payload }; default: return state; } } // Test: checkoutReducer({ step: 'cart' }, { type: 'SET_SHIPPING', payload: { zipCode: '90210' } }) // Output: { step: 'payment', shipping: { zipCode: '90210' }, payment: null, error: null }
Why Your Components Should Be Dumb and Your Reducers Smart
Look at your typical React component. It fetches data, manipulates state, handles UI events, and sometimes tries to do its own validation. That's the opposite of separation of concerns. That's a maintenance nightmare waiting for the next feature request.
With useReducer, you make components dumb. They read state from context, render it, and dispatch actions. That's it. The reducer owns all the logic — state transitions, validation, conditional flows. Your delete button doesn't know how to delete. It just dispatches { type: 'DELETE_ITEM' }. The reducer decides whether to optimistically remove the item, show a loading state, or roll back on error.
This patterns lets you change business logic without touching UI. New validation rule? Add it to the reducer. New UI framework? Your reducers are pure functions — they don't care about your JSX. You build a system that's resilient to change exactly where change happens most: business requirements.
// io.thecodeforge — javascript tutorial import { useCart, useCartDispatch } from './CartContext'; function CartItem({ id, name, price, quantity }) { const dispatch = useCartDispatch(); return ( <div> <span>{name} x {quantity}</span> <button onClick={() => dispatch({ type: 'REMOVE_ITEM', payload: id })}> Delete </button> </div> ); } function CartTotal() { const { items } = useCart(); // Dumb component — just reads state, no logic const total = items.reduce((sum, item) => sum + item.price * item.quantity, 0); return <h3>Total: ${total.toFixed(2)}</h3>; } // CartItem doesn't know about validation or API calls // CartTotal doesn't handle state mutations // All logic lives in the cartReducer
Github Repo: Stop Rewriting the Same Cart — Fork This
Every team builds the same useContext + useReducer shopping cart pattern. Waste of cycles. Instead of treating each project like a snowflake, grab a production-hardened starter that ships with typed reducers, context providers already wrapped, and a test harness.
The repo below isn't a tutorial. It's a battle kit. Clone it, rip out the ProductList, plug in your API, and your cart state is battle-ready in 10 minutes. The reducer handles add, remove, clear, and bulk update. Integration tests prove the context dispatches correctly without mounting the whole DOM. Unit tests validate every reducer branch — no more guessing "did I handle the edge case where quantity hits zero?"
Stop cargo-culting. One git clone, and you're shipping state logic that senior engineers trust in code review. The only thing you customize is the data shape. Everything else is vetted.
// io.thecodeforge — javascript tutorial // github.com/your-team/cart-starter // Production reducer with test validation const initialState = { items: [], total: 0 } function cartReducer(state, action) { switch (action.type) { case 'ADD_ITEM': const existing = state.items.find(i => i.id === action.payload.id) const updated = existing ? state.items.map(i => i.id === action.payload.id ? { ...i, qty: i.qty + 1 } : i) : [...state.items, { ...action.payload, qty: 1 }] return { ...state, items: updated } case 'REMOVE_ITEM': return { ...state, items: state.items.filter(i => i.id !== action.payload.id) } default: return state } }
Explanation
useContext passes a value object down the component tree. useReducer creates a state container and a dispatch function that triggers reducer logic. Combined, they give you centralized state with predictable updates — no Redux required. The reducer owns all mutation logic; components only call dispatch with a plain action object. This decouples UI from state transitions. The context provider wraps your tree, making dispatch and state available anywhere without prop threading. Every dispatch runs the reducer synchronously, producing the next state. React re-renders only the consumers of that context. This pattern works because the reducer is a pure function: same action + same state = same result. Predictable, testable, and easy to reason about. No subscriptions, no global singletons — just explicit data flow through React's own primitives. The explanation starts with the problem (shared mutable state across many components) and shows how these two hooks solve it without a library.
// io.thecodeforge — javascript tutorial import React, { createContext, useContext, useReducer } from 'react'; const CountContext = createContext(null); function countReducer(state, action) { switch (action.type) { case 'INCREMENT': return { count: state.count + 1 }; case 'DECREMENT': return { count: state.count - 1 }; default: return state; } } function Counter() { const { state, dispatch } = useContext(CountContext); return ( <> <p>Count: {state.count}</p> <button onClick={() => dispatch({ type: 'INCREMENT' })}>+</button> <button onClick={() => dispatch({ type: 'DECREMENT' })}>-</button> </> ); } export function App() { const [state, dispatch] = useReducer(countReducer, { count: 0 }); return ( <CountContext.Provider value={{ state, dispatch }}> <Counter /> </CountContext.Provider> ); }
Recap and Conclusion
UseContext gives you subscription-based state sharing without prop drilling. useReducer gives you predictable, testable state transitions. Together they replace small-to-medium state management needs without pulling in a library. The pattern breaks when performance matters — every context update re-renders all consumers. Split your contexts. Keep reducers pure. Never put derived data in state — compute it on the fly. If your reducer logic grows beyond 50 lines or you need middleware, consider Zustand or Redux Toolkit instead. This pattern solves the prop drilling tax, but it's not a scalable store. Use it for feature-local state that multiple components need. For global app state, reach for a real state manager. The final takeaway: useContext + useReducer gives you Redux-like discipline with zero dependencies. Start here, scale up when you hit hard walls.
// io.thecodeforge — javascript tutorial import React, { createContext, useContext, useReducer } from 'react'; const CartContext = createContext(null); function cartReducer(state, action) { switch (action.type) { case 'ADD': return { items: [...state.items, action.product] }; case 'REMOVE': return { items: state.items.filter(i => i.id !== action.id) }; default: return state; } } export function useCart() { const ctx = useContext(CartContext); if (!ctx) throw new Error('useCart must be inside CartProvider'); return ctx; } export function CartProvider({ children }) { const [state, dispatch] = useReducer(cartReducer, { items: [] }); return <CartContext.Provider value={{ state, dispatch }}>{children}</CartContext.Provider>; }
The Ghost Cart: Why Clearing the Cart Didn't Clear the UI
return { ...state, items: [] } and assumed that would work — but the totalPrice was derived from items.reduce(...) inside the same return, and the reducer was accidentally mutating the original array before returning.calculateTotals(items) that internally mutated the items array by sorting it (using items.sort() mutates in place). The reducer then spread the totals into a new state object, but the items reference was already mutated. Because the mutation happened before the new object was created, React's shallow comparison saw the same array reference and skipped the re-render for components reading items.items.sort() with [...items].sort() inside the helper to create a copy before sorting. Also add a lint rule: no-param-reassign for reducer files.- Pure reducers must create new objects and arrays — never mutate inputs.
- Eslint plugin-react-hooks and plugin-import catch common mutation patterns.
- Unit-test the reducer with deep equality assertions on every action.
- Derive totals inside the component using useMemo to decouple state shape from UI calculations.
JSON.stringify(prevState) === JSON.stringify(nextState) to detect accidental equality. If reducer mutates input (e.g., items.push()), the reference stays the same.useContext(CartStateContext). Also verify the Provider is not nested inside a conditional or another context that might not render. Use React DevTools to inspect the component tree and confirm the Provider exists above the consumer.useMemo if values are objects or arrays.case in the reducer. Typo in action type (e.g., 'ADD' vs 'ADD_ITEM') will hit default and return current state. Define action types as constants to prevent this.Add `console.log('state:', state, 'action:', action)` as first line of reducer.In browser console: right-click on rendered state → 'Store as global variable' to inspect.return { ...state, items: [...state.items, newItem] }.In DevTools: click component → see which contexts it inherits. Look for missing Provider.Add `if (!context) throw new Error('...')` in your custom hook to catch missing Provider early.Use `React.memo` on leaf components that don't need to re-render.Profile with React DevTools 'Highlight updates when components render' option.| Aspect | useState (local) | useReducer (local) | useContext + useReducer | Zustand | Redux Toolkit |
|---|---|---|---|---|---|
| Setup complexity | Zero — built in | Zero — built in | Low — two hooks, one context file | Low — create store, no Provider needed | Medium — store, slices, Provider |
| State scope | Single component | Single component | Component subtree (feature-level) | Global but can scope | Entire application |
| Logic complexity | Simple values only | Complex transitions, multiple cases | Complex transitions, multiple cases | Workflow-based, flexible | Complex async, middleware, side effects |
| Performance risk | None | None | Re-renders all consumers on update | Very low — no context re-render cascade | Minimal — optimised selectors built in |
| DevTools support | React DevTools only | React DevTools only | React DevTools only | Zustand DevTools (basic) | Redux DevTools with time travel |
| Testability of logic | Logic is in component | Reducer is a pure isolated function | Reducer is a pure isolated function | Store logic is isolated | Slices are pure isolated functions |
| Best for | Modal open/close, form fields | Single-component complex state | Cart, auth session, multi-step forms | Mid-size apps, frequent updates | Large teams, complex async workflows |
| Bundle size added | 0 bytes | 0 bytes | 0 bytes | ~2KB minified | ~12KB (RTK minified) |
| File | Command / Code | Purpose |
|---|---|---|
| ThemeContext.jsx | const ThemeContext = createContext('light'); | Why Prop Drilling Is a Real Problem (and What useContext Act |
| cartReducer.js | export const CART_ACTIONS = { | useReducer |
| MigrationExample.jsx | function ShoppingCartBefore() { | Migration Guide |
| CartFeature.jsx | const CartStateContext = createContext(null); // holds the state | Combining useContext + useReducer |
| ContextPerformanceAntiPattern.jsx | function BadProvider({ children }) { | When NOT to Use useContext Alone (Performance Pitfalls) |
| WhenToChoose.jsx | const [isMenuOpen, setIsMenuOpen] = useState(false); | When NOT to Use This Pattern (and What to Use Instead) |
| cartReducer.test.js | describe('cartReducer', () => { | Testing the Pattern in Isolation |
| ModalProvider.js | const ModalContext = createContext(null); | How Context + useReducer Bypasses the Prop Drilling Tax With |
| CheckoutReducer.js | const initialState = { | The Reducer Pattern That Scales Your Logic Without Scaling Y |
| ShoppingCart.js | function CartItem({ id, name, price, quantity }) { | Why Your Components Should Be Dumb and Your Reducers Smart |
| CartReducer.js | const initialState = { items: [], total: 0 } | Github Repo: Stop Rewriting the Same Cart |
| ExplainingThePattern.js | const CountContext = createContext(null); | Explanation |
| RecapExample.js | const CartContext = createContext(null); | Recap and Conclusion |
Key takeaways
Common mistakes to avoid
3 patternsMutating state inside the reducer (e.g., state.items.push(item))
return { ...state, items: [...state.items, newItem] }. For mutations like sort, create a copy first: [...items].sort().Putting the entire app state in one context
Forgetting the default case in the reducer switch
default: return state; in the switch. Define action types as constants to prevent typos. Use a logging wrapper around dispatch to catch unknown actions in development.Interview Questions on This Topic
What's the difference between useContext and prop drilling, and when would you choose useContext over simply lifting state up?
Why is a reducer function required to be pure, and what specific problems does impurity cause in a React application?
Date.now() or Math.random() inside a reducer makes the reducer non-deterministic — the same action could produce different states at different times, breaking time-travel debugging and making tests flaky.
- If the reducer throws an error (e.g., accessing undefined), React's error boundary may catch it but the state is left inconsistent. Pure reducers eliminate these categories of bugs entirely: given the same state and action, they always return the same result.In a production app, why might you split your context into a separate StateContext and DispatchContext rather than putting both in one? What performance problem does this solve, and can you describe a concrete scenario where it would matter?
Frequently Asked Questions
Absolutely — useReducer is a standalone hook that replaces useState for complex state logic within a single component or a small component subtree via props. You only need useContext when that state needs to be accessible to components at different levels of the tree without prop drilling. The two hooks are independent; they just work exceptionally well together.
For many mid-sized apps, useContext combined with useReducer can replace Redux entirely — especially if you don't need middleware, time-travel debugging, or a large team's worth of conventions. However, Redux Toolkit is still the better choice for large applications with complex async flows, many interacting state slices, or teams that benefit from the enforced structure Redux provides. Use the simplest tool that genuinely solves your problem.
Because your component is subscribed to a context that wraps both values in one object. When any property of that object changes, React creates a new object reference, all consumers see 'the value changed,' and all of them re-render. The fix is to split your context into separate, focused contexts — or use a library like Zustand that avoids this problem architecturally by not relying on React context for subscriptions.
Wrap the component in the appropriate Provider in your test. For integration tests, use React Testing Library to render the component inside a test Provider that provides a known state and a mock dispatch. For unit tests on the reducer, call the reducer function directly with a state and action — no rendering needed. This gives you both fast logic tests and reliable integration coverage.
20+ years shipping production JavaScript and front-end systems at scale. Everything here is grounded in real deployments.
That's React.js. Mark it forged?
11 min read · try the examples if you haven't