Senior 5 min · March 05, 2026

useContext useReducer—Ghost Cart: Mutation Skips Re-render

After clearing the cart, total price lingered because reducer's sort() mutated the array.

N
Naren · Founder
Plain-English first. Then code. Then the interview question.
About
 ● Production Incident 🔎 Debug Guide
Quick Answer
  • 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.
Plain-English First

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.

Every React app eventually hits the same wall. You have a piece of state — a logged-in user, a shopping cart, a theme preference — and you need it in five different components scattered across your component tree. So you start prop drilling: passing data down through parent, to child, to grandchild, to great-grandchild. Three weeks later your codebase looks like a game of telephone and changing one prop signature breaks four components. This is the problem that drives developers to reach for Redux before they're ready.

useContext and useReducer are React's built-in answer to that exact problem. useContext lets any component subscribe directly to a shared value without props being passed through every level in between. useReducer replaces useState for complex state logic — it separates the 'what happened' (the action) from the 'how the state changes' (the reducer), making your state transitions explicit, testable, and predictable. Together they form a lightweight but powerful state management pattern that's appropriate for a huge range of real apps.

By the end of this article you'll understand why each hook exists, when to reach for them versus simpler or heavier alternatives, and you'll have built a real working shopping cart feature using both hooks together. You'll also know the exact mistakes that trip up developers mid-interview and how to avoid them.

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.

ThemeContext.jsxJAVASCRIPT
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
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 AppCurrent 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>
  );
}
Output
Renders: Header showing 'Current theme: light', a paragraph showing 'Theme setting: light', and a Toggle Theme button.
After clicking Toggle Theme:
Header updates to 'Current theme: dark', paragraph updates to 'Theme setting: dark'.
No props were passed between App → Header or App → SettingsPanel.
Pro Tip: Always Build a Custom Hook
Never call useContext(SomeContext) directly in consumer components. Always wrap it in a custom hook like useTheme(). This lets you add the 'used outside Provider' error check once, gives you a cleaner import in consumer files, and makes refactoring far easier if the context shape ever changes.
Production Insight
A single context at the root of a large app can cause hundreds of components to re-render on any change.
Split contexts by domain — one for auth, one for theme, one for UI state.
Rule: a component should subscribe to exactly the context it needs, nothing more.
Key Takeaway
useContext eliminates prop drilling for genuinely global data.
Always wrap useContext in a custom hook for safety.
Scope contexts by concern — don't put everything in one.

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.

cartReducer.jsJAVASCRIPT
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
// 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;
  }
}
Output
// Pure function — no visual output. But you can test it directly:
// cartReducer(initialCartState, { type: 'ADD_ITEM', payload: { id: 1, name: 'Hoodie', price: 49.99 } })
// → { items: [{ id: 1, name: 'Hoodie', price: 49.99, quantity: 1 }], totalItems: 1, totalPrice: 49.99 }
// Call it again with the same item:
// cartReducer(previousState, { type: 'ADD_ITEM', payload: { id: 1, name: 'Hoodie', price: 49.99 } })
// → { items: [{ id: 1, name: 'Hoodie', price: 49.99, quantity: 2 }], totalItems: 2, totalPrice: 99.98 }
Watch Out: Never Mutate State in a Reducer
React detects state changes by reference comparison. If you do state.items.push(newItem) inside a reducer, the array reference doesn't change, React sees no difference, and your component won't re-render. Always return a brand-new object using spread syntax or array methods like map, filter, and concat that produce new arrays.
Production Insight
A reducer that mutates state silently drops updates — UI freezes with stale data.
Use eslint-plugin-react-hooks rule 'react/no-direct-mutation-state' to catch it.
Rule: reducer must be a pure function with zero side effects — no API calls, no Date.now(), no Math.random().
Key Takeaway
useReducer centralizes complex state transitions into a pure, testable function.
Never mutate state — return new objects/arrays.
Action types as constants prevent silent typos.

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.

CartFeature.jsxJAVASCRIPT
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
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>
  );
}
Output
Initial render:
'Dev Gear Shop' heading
Three product cards: Mechanical Keyboard $129.99, USB-C Hub $39.99, Webcam HD $79.99
'Your cart is empty.'
After clicking 'Add to Cart' on Mechanical Keyboard twice, then USB-C Hub once:
Cart (3 items)
Mechanical Keyboard $129.99 × 2 [− button] [+ button] [Remove button]
USB-C Hub $39.99 × 1 [− button] [+ button] [Remove button]
Total: $299.97
[Clear Cart button]
Clicking − on Mechanical Keyboard:
Mechanical Keyboard $129.99 × 1
Total: $169.98
Clicking − again removes Mechanical Keyboard from the list entirely.
Clicking Clear Cart resets to 'Your cart is empty.'
Interview Gold: Two Contexts, Not One
Splitting state and dispatch into separate contexts is a real performance pattern used in production. A component that only dispatches actions — like an 'Add to Cart' button — doesn't need to know the current cart contents. By consuming only CartDispatchContext, it opts out of re-renders triggered by state changes. This is a detail that immediately signals senior-level React knowledge in an interview.
Production Insight
Exposing dispatch directly keeps the Provider stable — no need to update it when adding new actions.
Separate state and dispatch contexts to prevent unnecessary re-renders in dispatch-only components.
Rule: Provider should never import UI code — it only wires state and dispatch.
Key Takeaway
Combine useContext for scope and useReducer for logic.
Expose dispatch directly, not wrapped callbacks.
Split contexts: one for state (subscribed by readers), one for dispatch (subscribed by writers).

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.

WhenToChoose.jsxJAVASCRIPT
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
// 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.
Output
// No runtime output — this is an architectural reference.
// The rule: start simple, move right only when forced to.
Watch Out: Context Is Not a Silver Bullet
One pattern to actively avoid: a single giant AppContext that holds user, cart, settings, notifications, and UI state all in one object. When any one of those updates, every consumer re-renders. Split your contexts by concern — one for auth, one for cart, one for UI preferences — and each component only subscribes to what it actually needs.
Production Insight
A single AppContext with multiple slices causes unnecessary re-renders across the entire tree.
High-frequency state (animations, mouse) should stay in local useState.
Rule: if you need Redux DevTools or middleware, don't force this pattern — use a proper state management library.
Key Takeaway
Context+useReducer is for feature-scoped state, not app-wide global state.
For high-frequency updates or complex async, prefer Zustand or Redux Toolkit.
Rule: start simple, move to a heavier tool only when the current one breaks.

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?

cartReducer.test.jsJAVASCRIPT
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
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
Output
Running `npm test cartReducer.test.js`:
✓ ADD_ITEM adds a new item with quantity 1
✓ ADD_ITEM on existing item increments quantity
✓ DECREMENT_QTY removes item when quantity hits 0
✓ CLEAR_CART resets to initial state
✓ unknown action returns current state
All tests pass without mounting any React components.
Pro Tip: Test the Reducer, Then the Integration
Production Insight
A reducer with 100% unit test coverage but no integration test missed a bug in context wiring — dispatch wasn't reaching the reducer.
Integration tests catch Provider misconfiguration, like wrapping the wrong subtree.
Rule: unit test the reducer for logic; integration test the wiring once.
Key Takeaway
Reducer is a pure function — test it in isolation (fast, no UI overhead).
Integration tests verify the Provider+context+dispatch wiring.
Rule: logic tested per action, wiring tested once.
● Production incidentPOST-MORTEMseverity: high

The Ghost Cart: Why Clearing the Cart Didn't Clear the UI

Symptom
After clicking 'Clear Cart', the cart display showed zero items but the total price remained non-zero. Refreshing the page fixed it temporarily.
Assumption
The team assumed the reducer was returning a new state object. They'd written 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.
Root cause
The reducer had a helper function 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.
Fix
Replace 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.
Key lesson
  • 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.
Production debug guideSymptom → Action table for the most common runtime failures4 entries
Symptom · 01
Component doesn't re-render after dispatch
Fix
Check reducer: is the returned state object a new reference? Use JSON.stringify(prevState) === JSON.stringify(nextState) to detect accidental equality. If reducer mutates input (e.g., items.push()), the reference stays the same.
Symptom · 02
useContext returns undefined despite being inside Provider
Fix
Check that the custom hook correctly calls 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.
Symptom · 03
All components re-render when one piece of context changes
Fix
Split your context: separate StateContext from DispatchContext. Ensure dispatch-only components consume only DispatchContext. For state context, memoize provider value with useMemo if values are objects or arrays.
Symptom · 04
Action dispatched but state shows no change
Fix
Verify the action type string matches a 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.
★ Quick Debug Cheat Sheet: useContext + useReducerImmediate steps to diagnose state issues without digging through the whole component tree. Each row is a 2-minute diagnostic you can run during an incident.
State not updating after dispatch
Immediate action
Log the action and current state inside the reducer before switch.
Commands
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.
Fix now
Ensure the reducer returns a new object, not the mutated state. return { ...state, items: [...state.items, newItem] }.
Context value is undefined in consumer+
Immediate action
Check if the component is inside the Provider. Use React DevTools component tree.
Commands
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.
Fix now
Wrap the component tree (or at least the consumer) inside the correct Provider. Move Provider higher up if needed.
Re-render cascade on every keystroke+
Immediate action
Check if state is too high in tree (e.g., root App) and every consumer is subscribed.
Commands
Use `React.memo` on leaf components that don't need to re-render.
Profile with React DevTools 'Highlight updates when components render' option.
Fix now
Split the context into multiple smaller contexts or move frequently-changing state to local useState.
AspectuseState (local)useContext + useReducerRedux Toolkit
Setup complexityZero — built inLow — two hooks, one context fileMedium — store, slices, Provider
State scopeSingle componentComponent subtree (feature-level)Entire application
Logic complexitySimple values onlyComplex transitions, multiple casesComplex async, middleware, side effects
Performance riskNoneRe-renders all consumers on updateMinimal — optimised selectors built in
DevTools supportReact DevTools onlyReact DevTools onlyRedux DevTools with time travel
Testability of logicLogic is in componentReducer is a pure isolated functionSlices are pure isolated functions
Best forModal open/close, form fieldsCart, auth session, multi-step formsLarge teams, complex async workflows
Bundle size added0 bytes0 bytes~12KB (Redux Toolkit minified)

Key takeaways

1
useContext solves the 'prop drilling' problem by creating a direct channel between a Provider and any descendant
no intermediate components required — but it re-renders every subscriber on every value change, so split contexts by concern.
2
useReducer shines when state transitions are conditional and multi-step
the reducer's switch statement becomes a single, auditable source of truth for how your state can change, and because it's a pure function, you can unit test it without React at all.
3
The useContext + useReducer combination follows a clear architecture
the reducer owns business logic, the Provider owns state lifecycle, and consumer components own only rendering — each layer has exactly one job.
4
This pattern is not a Redux replacement for large apps
it's best for feature-scoped state (a checkout flow, a form wizard, an auth session) shared across a subtree. For app-wide, frequently-updated, or async-heavy state, Redux Toolkit or Zustand are more appropriate tools.
5
Always split state and dispatch into separate contexts to avoid re-rendering components that only dispatch actions.
6
Write unit tests for the reducer in isolation
it's a pure function with no React dependency.

Common mistakes to avoid

3 patterns
×

Mutating state inside the reducer (e.g., state.items.push(item))

Symptom
Component does not re-render after dispatch even though console logs show the state changed. The array reference stays the same because push modifies in place. React's shallow comparison sees the same reference and skips the render.
Fix
Always return a new object/array. Use spread syntax: return { ...state, items: [...state.items, newItem] }. For mutations like sort, create a copy first: [...items].sort().
×

Putting the entire app state in one context

Symptom
All components that consume the context re-render when any piece of state changes, even if they only use a different piece. Causes performance degradation as the app grows.
Fix
Split contexts by domain (AuthContext, CartContext, UIContext). Each component subscribes only to the context it needs. For state that changes frequently, keep it in local useState or use a library like Zustand.
×

Forgetting the default case in the reducer switch

Symptom
A typo in the action type (e.g., 'add_item' vs 'ADD_ITEM') causes the reducer to fall through without a matching case. If no default exists, the switch returns undefined and the state becomes undefined -> crash. Even with default, without returning current state, the state becomes undefined.
Fix
Always add 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 PREP · PRACTICE MODE

Interview Questions on This Topic

Q01SENIOR
What's the difference between useContext and prop drilling, and when wou...
Q02SENIOR
Why is a reducer function required to be pure, and what specific problem...
Q03SENIOR
In a production app, why might you split your context into a separate St...
Q01 of 03SENIOR

What's the difference between useContext and prop drilling, and when would you choose useContext over simply lifting state up?

ANSWER
Prop drilling passes data through every intermediate component via props, even if those components don't need the data. useContext creates a direct channel from a Provider to any descendant that calls useContext. Lifting state up is appropriate when just two sibling components need shared state — no prop drilling through deep trees. useContext is better when multiple distant components at different depths need the same data — like theme, auth, or cart — because it avoids coupling intermediate components to data they don't use. The key trade-off: lifting state up is simpler for local shared state, useContext scales better for cross-tree data but adds a dependency on the context subscription.
FAQ · 4 QUESTIONS

Frequently Asked Questions

01
Can I use useReducer without useContext in React?
02
Does useContext replace Redux?
03
Why does my component re-render when a context value it doesn't care about changes?
04
How do I test a component that uses useContext?
🔥

That's React.js. Mark it forged?

5 min read · try the examples if you haven't

Previous
React Hooks — useState and useEffect
5 / 47 · React.js
Next
React useMemo and useCallback