Implement context performance in a product component
Use context performance to solve a realistic React workflow. Keep rendering, user intent, async synchronization, and error states separate.
Answer Strategy
Context performance is the question that exposes whether you understand React’s re-render rules at the boundary of a Provider. The default behavior catches engineers off guard: every consumer of a context re-renders whenever the value identity changes, even if it only reads a field that did not move. The fix is structural — split the context so identity-stable parts (dispatch, set functions) live in one provider and value-changing parts (state) live in another.
Three legitimate fixes; pick by shape. Split state and setters across two contexts when the API is small (the canonical reducer pattern). Use a selector + useSyncExternalStore-style subscription store when consumers want a slice of a big object (zustand, jotai, redux do this internally). Memoize the value object passed to a single Provider when the data really is one cohesive unit and consumers all need it together.
Adjacent traps: passing { state, dispatch } in one Provider and memoizing it (the deps are state, so it changes anyway), wrapping consumers in React.memo without a stable context value (still re-renders), and treating Context.Provider as global state for the whole app (you will end up rebuilding redux poorly). The reference splits the contexts and gates dispatch-only consumers behind React.memo so the test demonstrates the win.
Reference Implementation: Split State And Dispatch Contexts
A cart provider that exposes state and dispatch as two separate contexts so dispatch-only consumers stay out of the re-render path.
// The context-performance trick: split state and setters into two contexts.
// Components that only dispatch never re-render when state changes, and
// components that only read state are not re-bound on every dispatch.
type CartState = { items: string[] };
type CartDispatch = React.Dispatch<{ type: 'add' | 'clear'; item?: string }>;
const CartStateContext = React.createContext<CartState | null>(null);
const CartDispatchContext = React.createContext<CartDispatch | null>(null);
function reducer(state: CartState, action: { type: 'add' | 'clear'; item?: string }) {
if (action.type === 'add' && action.item)
return { items: [...state.items, action.item] };
if (action.type === 'clear') return { items: [] };
return state;
}
export function CartProvider({ children }: { children: React.ReactNode }) {
const [state, dispatch] = React.useReducer(reducer, { items: [] });
// Two providers, two contexts: dispatch identity is stable for the
// lifetime of the provider, so dispatch-only consumers never re-render
// because of state changes.
return (
<CartDispatchContext.Provider value={dispatch}>
<CartStateContext.Provider value={state}>{children}</CartStateContext.Provider>
</CartDispatchContext.Provider>
);
}
export function useCartState() {
const ctx = React.useContext(CartStateContext);
if (!ctx) throw new Error('useCartState must be used inside CartProvider');
return ctx;
}
export function useCartDispatch() {
const ctx = React.useContext(CartDispatchContext);
if (!ctx) throw new Error('useCartDispatch must be used inside CartProvider');
return ctx;
}
// AddButton subscribes to dispatch only. State changes do not re-render it.
export const AddButton = React.memo(function AddButton({ item }: { item: string }) {
const dispatch = useCartDispatch();
return <button onClick={()=> dispatch({ type: 'add', item })}>Add {item}</button>;
});
export function CartCount() {
const { items } = useCartState();
return <span aria-label="cart count">{items.length}</span>;
}Testing Strategy
Convert the answer into observable behavior. In a mid-senior interview, say which behaviors are covered by unit tests, interaction tests, accessibility checks, and one browser smoke path.
import { describe, it, expect, vi } from 'vitest';
import { render, screen, fireEvent } from '@testing-library/react';
describe('Cart context split', () => {
it('dispatch-only components do not re-render on state change', () => {
let renders = 0;
const Probe = React.memo(function Probe() {
renders++;
const dispatch = useCartDispatch();
return <button onClick={()=> dispatch({ type: 'add', item: 'x' })}>Add</button>;
});
render(
<CartProvider>
<Probe />
<CartCount />
</CartProvider>
);
expect(renders).toBe(1);
fireEvent.click(screen.getByText('Add'));
expect(screen.getByLabelText('cart count')).toHaveTextContent('1');
// Probe only consumed dispatch; dispatch identity is stable across
// reducer commits, so memo prevents a second render.
expect(renders).toBe(1);
});
it('throws if hooks are used outside the provider', () => {
const spy = vi.spyOn(console, 'error').mockImplementation(() => {});
expect(() => render(<CartCount />)).toThrow();
spy.mockRestore();
});
});Interviewer Signal
Shows whether you can explain React behavior while building maintainable product UI.
Constraints
- Name what is render-derived and what is stored state.
- Keep side effects owned by events or effects deliberately.
- Provide a testable boundary for business logic.
Model Answer Shape
- Start from the user workflow and state ownership.
- Move pure decisions out of JSX when the branch logic grows.
- Use React primitives to express ownership, not to hide unclear state.
Tradeoffs
- Colocating state improves clarity until sibling coordination becomes the real problem.
- Memoization helps only after render cost or identity churn is measured.
Edge Cases
- Strict Mode re-running development effects.
- Stale closures after async work resolves.
- Unmounts and route changes during in-flight operations.
Testing And Proof
- Reducer or pure function test for core state transitions.
- Interaction test for the user workflow.
- Regression case for stale or repeated async behavior.
Follow-Ups
- How would this change with server rendering?
- Where would you place this state in a larger app?