DevLift
Back to Blog

React Context API Done Right: Stop Prop Drilling Without Torching Performance

React Context is great for static config but a footgun for dynamic state. Learn how to split contexts by update frequency and separate state from dispatch.

Admin
August 2, 20267 min read0 views

React Context API Done Right: Stop Prop Drilling Without Torching Performance

You've got a deeply nested component tree. A user object lives at the top, a handful of components five levels down need it, and you're tired of passing it through every intermediate component that doesn't care. Context looks like the obvious fix.

So you create one big context, shove everything in it — user, theme, notifications, cart count — and wrap your whole app. It works. You ship it. Then someone notices the search input lags on every keystroke, and your performance profiler shows every component in the tree re-rendering on every character typed.

That's the Context footgun. And most teams hit it because the naive implementation is so easy to write.

What Actually Happens When Context Updates

React's context is not a subscription system. It's a broadcasting system. When a context value changes, React re-renders every component that reads from that context — regardless of whether the specific piece of data that component uses actually changed.

Here's the innocent-looking code that causes the problem:

// Don't do this
const AppContext = createContext<AppState | null>(null);
 
function AppProvider({ children }: { children: React.ReactNode }) {
  const [user, setUser] = useState<User | null>(null);
  const [theme, setTheme] = useState<'light' | 'dark'>('light');
  const [searchQuery, setSearchQuery] = useState('');
  const [cartCount, setCartCount] = useState(0);
 
  return (
    <AppContext.Provider value={{ user, theme, searchQuery, cartCount, setUser, setTheme, setSearchQuery, setCartCount }}>
      {children}
    </AppContext.Provider>
  );
}

Now searchQuery changes on every keystroke. Every component reading from AppContext re-renders — including your UserAvatar deep in the nav, your CartIcon in the header, your ThemeToggle in settings. None of them care about searchQuery. They all re-render anyway.

Rendering diagram...

Only SearchBar actually needed to update. Everything else is wasted work.

The Fix: Split Contexts by Update Frequency

The core insight is that not all state changes at the same rate. user changes once on login. theme changes when someone clicks a toggle. searchQuery changes on every keystroke.

Group state by how often it changes, and give each group its own context.

// contexts/UserContext.tsx
const UserStateContext = createContext<User | null>(null);
const UserDispatchContext = createContext<Dispatch<UserAction> | null>(null);
 
export function UserProvider({ children }: { children: React.ReactNode }) {
  const [user, dispatch] = useReducer(userReducer, null);
 
  return (
    <UserDispatchContext.Provider value={dispatch}>
      <UserStateContext.Provider value={user}>
        {children}
      </UserStateContext.Provider>
    </UserDispatchContext.Provider>
  );
}
 
export function useUser() {
  const ctx = useContext(UserStateContext);
  // The context default is `null`, so guard on null — a `=== undefined`
  // check here can never fire and the error message never ships.
  if (ctx === null) throw new Error('useUser must be inside UserProvider');
  return ctx;
}
 
export function useUserDispatch() {
  const ctx = useContext(UserDispatchContext);
  if (ctx === null) throw new Error('useUserDispatch must be inside UserProvider');
  return ctx;
}
// contexts/SearchContext.tsx
const SearchContext = createContext<string>('');
const SetSearchContext = createContext<(q: string) => void>(() => {});
 
export function SearchProvider({ children }: { children: React.ReactNode }) {
  const [query, setQuery] = useState('');
 
  return (
    <SetSearchContext.Provider value={setQuery}>
      <SearchContext.Provider value={query}>
        {children}
      </SearchContext.Provider>
    </SetSearchContext.Provider>
  );
}
 
export const useSearchQuery = () => useContext(SearchContext);
export const useSetSearch = () => useContext(SetSearchContext);

Now SearchBar re-renders on every keystroke, UserAvatar doesn't. The CartIcon subscribes only to cart state. Each component opts into exactly the data it needs.

Why Splitting State from Dispatch Matters

This is the pattern the React team recommends in their docs, and it's worth understanding why it's not just cargo-culting.

The dispatch function from useReducer is stable — React guarantees it never changes between renders. So if you put dispatch in its own context, any component that only needs to trigger actions (like a "Log Out" button) never re-renders when the state changes.

function LogoutButton() {
  // Reads only the dispatch function, which is stable — so a user-state
  // change does not force this component to re-render.
  const dispatch = useUserDispatch();
  return <button onClick={() => dispatch({ type: 'LOGOUT' })}>Log out</button>;
}

Without this split, LogoutButton would re-render whenever user state changed — even though it doesn't display any user data.

Mounted and render-counted on React 19.2.3: with the tree above, two dispatches produced 1 LogoutButton render and 3 renders of the component reading state. So the split works.

⚠️

That result depends on something subtler than the split itself. UserProvider re-renders on every state change, but its children prop is the same element object it received last time, so React bails out of re-rendering the children subtree and only force-updates the actual context consumers. Move the JSX inside the provider component — return <Ctx.Provider value={s}><LogoutButton /><Display /></Ctx.Provider> — and the bailout disappears: the same probe then showed LogoutButton re-rendering along with everything else. Split contexts protect you from context re-renders, not from a parent re-rendering its own JSX.

The same logic applies to setter functions from useState. Put them in separate contexts if many components only need to trigger updates.

Building a Real Context with useReducer

Here's a more complete example of a shopping cart context that you'd actually find in a production app:

// contexts/CartContext.tsx
import { createContext, useContext, useReducer, type Dispatch } from 'react';
 
type CartItem = {
  id: string;
  name: string;
  price: number;
  quantity: number;
};
 
type CartState = {
  items: CartItem[];
  isOpen: boolean;
};
 
type CartAction =
  | { type: 'ADD_ITEM'; item: Omit<CartItem, 'quantity'> }
  | { type: 'REMOVE_ITEM'; id: string }
  | { type: 'UPDATE_QUANTITY'; id: string; quantity: number }
  | { type: 'TOGGLE_CART' }
  | { type: 'CLEAR' };
 
function cartReducer(state: CartState, action: CartAction): CartState {
  switch (action.type) {
    case 'ADD_ITEM': {
      const existing = state.items.find((i) => i.id === action.item.id);
      if (existing) {
        return {
          ...state,
          items: state.items.map((i) =>
            i.id === action.item.id ? { ...i, quantity: i.quantity + 1 } : i,
          ),
        };
      }
      return { ...state, items: [...state.items, { ...action.item, quantity: 1 }] };
    }
    case 'REMOVE_ITEM':
      return { ...state, items: state.items.filter((i) => i.id !== action.id) };
    case 'UPDATE_QUANTITY':
      return {
        ...state,
        items: state.items.map((i) =>
          i.id === action.id ? { ...i, quantity: action.quantity } : i,
        ),
      };
    case 'TOGGLE_CART':
      return { ...state, isOpen: !state.isOpen };
    case 'CLEAR':
      return { ...state, items: [] };
    default:
      return state;
  }
}
 
const CartStateContext = createContext<CartState | null>(null);
const CartDispatchContext = createContext<Dispatch<CartAction> | null>(null);
 
const initialState: CartState = { items: [], isOpen: false };
 
export function CartProvider({ children }: { children: React.ReactNode }) {
  const [state, dispatch] = useReducer(cartReducer, initialState);
 
  return (
    <CartDispatchContext.Provider value={dispatch}>
      <CartStateContext.Provider value={state}>
        {children}
      </CartStateContext.Provider>
    </CartDispatchContext.Provider>
  );
}
 
// Derived selectors — compute what you need
export function useCartItems() {
  const state = useContext(CartStateContext);
  if (!state) throw new Error('useCartItems must be used inside CartProvider');
  return state.items;
}
 
export function useCartCount() {
  const state = useContext(CartStateContext);
  if (!state) throw new Error('useCartCount must be used inside CartProvider');
  return state.items.reduce((sum, item) => sum + item.quantity, 0);
}
 
export function useCartIsOpen() {
  const state = useContext(CartStateContext);
  if (!state) throw new Error('useCartIsOpen must be used inside CartProvider');
  return state.isOpen;
}
 
export function useCartDispatch() {
  const dispatch = useContext(CartDispatchContext);
  if (!dispatch) throw new Error('useCartDispatch must be used inside CartProvider');
  return dispatch;
}

The custom hooks at the bottom are key. useCartCount and useCartIsOpen both read from CartStateContext, which means they'll re-render whenever any cart state changes — even if the count didn't change. That's the remaining limitation of Context: it has no selector support built in.

For most apps, this is fine. For high-frequency updates on large component trees, it's not.

React 19: <Context> Is the Provider

Everything above uses <SomeContext.Provider value={...}>, which is what you'll find in every existing codebase. React 19 lets you render the context object itself:

const ThemeContext = createContext<Theme | null>(null);
 
// React 19 — no `.Provider`
<ThemeContext value={theme}>{children}</ThemeContext>
 
// Equivalent, and still what most code looks like today
<ThemeContext.Provider value={theme}>{children}</ThemeContext.Provider>

Both work in 19.2 (verified: a consumer reads the value identically through either form). React has said Context.Provider will be deprecated in a future release, so new code may as well use the short form. React 19 also lets you read a context with use(ThemeContext) instead of useContext, which — unlike useContext — is allowed inside conditionals and loops.

Stabilizing Context Values with useMemo

If your context value is constructed inline, every provider render creates a new object reference, which triggers all consumers to re-render even if the underlying values are identical.

function ThemeProvider({ children }: { children: React.ReactNode }) {
  const [theme, setTheme] = useState<'light' | 'dark'>('light');
  const toggleTheme = useCallback(
    () => setTheme((t) => (t === 'light' ? 'dark' : 'light')),
    [],
  );
 
  // ❌ New object on every render — every consumer re-renders,
  //    even when `theme` is unchanged.
  // return <ThemeContext value={{ theme, toggleTheme }}>{children}</ThemeContext>;
 
  // ✅ New object only when `theme` changes.
  const value = useMemo(() => ({ theme, toggleTheme }), [theme, toggleTheme]);
  return <ThemeContext value={value}>{children}</ThemeContext>;
}

Measured, again with a render counter: an unrelated provider re-render with an inline value object of identical contents re-rendered the consumer (2 renders); with useMemo it did not (1 render).

The useCallback on toggleTheme isn't optional decoration. Without it the function is a new reference each render, which makes the useMemo above recompute every render, which puts you right back where you started.

⚠️

Don't reflexively add useMemo to every context. If the state changes frequently anyway, memoizing the value object doesn't help — you're just adding overhead. Profile first.

How the Optimized Tree Looks

With split contexts and stable references, the re-render story changes completely:

Rendering diagram...

Each context update only touches its own subscribers. Unrelated components are completely untouched.

When Context Is the Wrong Tool

Context is not state management. It's dependency injection. That distinction matters.

Reach for Zustand, Jotai, or Redux Toolkit when:

  • State changes frequently and many unrelated components subscribe to different slices of it. External stores let individual components subscribe to exactly the slice they need — no unnecessary re-renders.
  • You need devtools with time-travel debugging. Redux Toolkit gives you this out of the box.
  • Your state logic is complex enough that you want to test it in isolation, outside the React tree.
  • You're building a large app where multiple teams work on different features. A centralized store with slices is easier to manage than a dozen nested providers.
// Zustand — components subscribe to exactly the slice they need
const useCartStore = create<CartStore>((set) => ({
  items: [],
  addItem: (item) => set((state) => ({ items: [...state.items, item] })),
}));
 
// This component ONLY re-renders when items changes, not any other store state
function CartCount() {
  const count = useCartStore((state) => state.items.length);
  return <span>{count}</span>;
}

Context can't do this without third-party libraries like use-context-selector. If you find yourself wanting selector-style subscriptions, you've already outgrown Context for that use case.

Context is genuinely good for:

  • Theme and color scheme (low-frequency, read by many components)
  • Locale and i18n strings (static after initialization)
  • Feature flags (set once on load)
  • Current user (changes once on login/logout)
  • Design system configuration (font scales, spacing tokens)

These are cases where the data is essentially static or changes rarely, and the broadcast-on-change behavior is acceptable because changes are infrequent.

Variations Worth Knowing

Default value as a null guard. Create your context with null as the default, then throw in the hook if it's null. This gives you a clear error when a component is used outside its provider instead of silent undefined bugs.

Multiple providers composing cleanly. As your provider list grows, extract a single AppProviders component so your app root stays readable:

function AppProviders({ children }: { children: React.ReactNode }) {
  return (
    <UserProvider>
      <CartProvider>
        <SearchProvider>
          <ThemeProvider>
            {children}
          </ThemeProvider>
        </SearchProvider>
      </CartProvider>
    </UserProvider>
  );
}
 
// app/layout.tsx
export default function RootLayout({ children }) {
  return (
    <html>
      <body>
        <AppProviders>{children}</AppProviders>
      </body>
    </html>
  );
}

Context in server components (Next.js App Router). Context only works in Client Components. If you're using the App Router, you can't use context in Server Components directly. Wrap context providers in a 'use client' boundary and keep Server Components as leaves in the tree — they don't need context because they fetch their own data.

// app/providers.tsx
'use client';
 
export function Providers({ children }: { children: React.ReactNode }) {
  return (
    <CartProvider>
      <UserProvider>
        {children}
      </UserProvider>
    </CartProvider>
  );
}

The Practical Decision Framework

Before you reach for Context, ask:

  1. How often does this data change? Keystroke- or scroll-frequency updates broadcast to a wide consumer tree are where Context stops being free. Profile that case; if it's hot, reach for Zustand or Jotai.
  2. How many components consume it? If it's two or three, just pass props. The complexity isn't worth it.
  3. Does any consumer only need part of the data? If yes, either split the context or consider an external store with selectors.
  4. Is this configuration or application state? Configuration (theme, locale, feature flags) is ideal for Context. Dynamic state is often better off in a dedicated store.

The pattern that gets teams in trouble is treating Context as a global state manager for everything. It's not that. It's a way to make certain values available anywhere in the tree without prop drilling. Use it for that, and reach for purpose-built tools when you need actual state management.

One last thing worth remembering: the re-render from Context is often not the bottleneck you think it is. React's reconciler is fast, and a re-render that produces identical output writes nothing to the DOM. But be precise about what that does and does not save you — a component whose output never changes still has its function body executed on every render (measured: 3 renders across 3 parent updates). React skips the DOM mutation, not the render. If the render itself is expensive, only memo or a narrower subscription helps. Profile before you optimize. Add React.memo around expensive components before you rearchitect your context. The split state/dispatch pattern and separate contexts by concern are worth doing upfront, but most apps won't need more than that.

Comments (0)

No comments yet. Be the first to share your thoughts!

Related Articles

TypeScript ships with 15+ utility types that let you derive new types from existing ones. Here's the full toolkit with the production patterns you'll actually reach for.
AdminAugust 2, 20265 min read
Stop casting with `as` and `!`. Discriminated unions give TypeScript enough information to narrow types automatically — making impossible states unrepresentable and exhaustiveness checking free.
AdminAugust 2, 20265 min read
TypeScript is the default for serious JavaScript work now — but the real question is when the type overhead pays off, and which parts of your codebase genuinely don't need it.
AdminAugust 2, 20268 min read