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
importReact, { 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.constThemeContext = createContext('light');
// Step 2: Build a custom Provider component.// This keeps all theme-related state and logic in one place.functionThemeProvider({ children }) {
const [theme, setTheme] = useState('light');
// We pass both the value AND the updater so consumers can read and writeconst 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.functionuseTheme() {
const context = useContext(ThemeContext);
if (!context) {
// This fires if someone uses useTheme() outside of ThemeProviderthrownewError('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.functionHeader() {
const { theme } = useTheme(); // reads theme, doesn't need toggleThemereturn (
<header style={{ background: theme === 'dark' ? '#1a1a1a' : '#ffffff', color: theme === 'dark' ? '#fff' : '#000', padding: '1rem' }}>
<h1>MyApp — Current theme: {theme}</h1>
</header>
);
}
functionSettingsPanel() {
const { theme, toggleTheme } = useTheme(); // reads and writesreturn (
<div style={{ padding: '1rem' }}>
<p>Theme setting: <strong>{theme}</strong></p>
<button onClick={toggleTheme}>ToggleTheme</button>
</div>
);
}
// Step 4: Wrap your app (or relevant subtree) in the Provider.// Header and SettingsPanel can be nested ANY depth and still work.exportdefaultfunctionApp() {
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')exportconst 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 everywhereexportconst initialCartState = {
items: [], // Array of { id, name, price, quantity }
totalItems: 0,
totalPrice: 0,
};
// Helper: recalculate totals whenever items changefunctioncalculateTotals(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 changesexportfunctioncartReducer(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 entirelyconst 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 itemsreturn {
...state,
items: decrementedItems,
...calculateTotals(decrementedItems),
};
}
case CART_ACTIONS.CLEAR_CART:
return initialCartState; // reset to the original empty statedefault:
// Returning current state on unknown actions prevents silent failuresreturn state;
}
}
Output
// Pure function — no visual output. But you can test it directly:
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.
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 componentconst [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?
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.
Aspect
useState (local)
useContext + useReducer
Redux Toolkit
Setup complexity
Zero — built in
Low — two hooks, one context file
Medium — store, slices, Provider
State scope
Single component
Component subtree (feature-level)
Entire application
Logic complexity
Simple values only
Complex transitions, multiple cases
Complex async, middleware, side effects
Performance risk
None
Re-renders all consumers on update
Minimal — optimised selectors built in
DevTools support
React DevTools only
React DevTools only
Redux DevTools with time travel
Testability of logic
Logic is in component
Reducer is a pure isolated function
Slices are pure isolated functions
Best for
Modal open/close, form fields
Cart, auth session, multi-step forms
Large teams, complex async workflows
Bundle size added
0 bytes
0 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.
Q02 of 03SENIOR
Why is a reducer function required to be pure, and what specific problems does impurity cause in a React application?
ANSWER
A reducer must be pure because React relies on the reducer's output to determine what to render. Impurities break this contract in several ways:
- Side effects like API calls inside a reducer cause state to depend on response timing, making re-renders unpredictable and potentially triggering infinite loops.
- Mutating the previous state prevents React from detecting changes (reference equality), so UI won't update.
- Calling 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.
Q03 of 03SENIOR
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?
ANSWER
When both state and dispatch are in a single context, every consumer that calls useContext re-renders whenever the state changes — even consumers that only need dispatch (like buttons). Splitting into two contexts allows components to subscribe to only what they need. A button that dispatches an action subscribes to DispatchContext, which never changes (dispatch identity is stable), so it never re-renders. A display component subscribes to StateContext and re-renders only when relevant state changes. Concrete scenario: a product list with 100 'Add to Cart' buttons, each subscribing to a single combined cart context. When one item is added, all 100 buttons re-render — causing 100x extra work. With split contexts, only the cart summary component re-renders; the buttons stay mounted. This matters on low-powered devices or with large lists where unnecessary re-renders cause jank.
01
What's the difference between useContext and prop drilling, and when would you choose useContext over simply lifting state up?
SENIOR
02
Why is a reducer function required to be pure, and what specific problems does impurity cause in a React application?
SENIOR
03
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?
SENIOR
FAQ · 4 QUESTIONS
Frequently Asked Questions
01
Can I use useReducer without useContext in React?
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.
Was this helpful?
02
Does useContext replace Redux?
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.
Was this helpful?
03
Why does my component re-render when a context value it doesn't care about changes?
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.
Was this helpful?
04
How do I test a component that uses useContext?
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.