← Back to question bank
React AppMidMedium#4015 · 40m

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.

Re-render isolation
Wrap a dispatch-only component in React.memo, count its renders, dispatch a state-changing action. The render count must stay flat. Without the split, the count climbs because the single Provider value changed identity.
Provider boundary
Render the consumer hook outside the provider; it must throw a clear error rather than silently returning null. This catches the common bug of forgetting to wrap a tree with the provider in tests.
Selector escape hatch
For larger state shapes, document when to switch from "split contexts" to "selector store". The cutoff is roughly: more than three frequently-mutated fields, or consumers that need different slices on different routes.
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?