React SME Cookbook
All FAQs

Search Documentation

Search across all documentation pages

contextproviderselectorsplittingperformancereact-patterns

Context Patterns — Split, scope, and optimize React context to avoid unnecessary re-renders

Recipe

import { createContext, use, useState, useCallback, type ReactNode } from "react";
 
// Split context: separate state from dispatch
const CountStateContext = createContext<number>(0);
const CountDispatchContext = createContext<{
  increment: () => void;
  decrement: () => void;
} | null>(null);
 
function CountProvider({ children }: { children: ReactNode }) {
  const [count, setCount] = useState(0);
  const dispatch = useMemo(() => ({
    increment: () => setCount((c) => c + 1),
    decrement: () => setCount((c) => c - 1),
  }), []);
 
  return (
    <CountStateContext value={count}>
      <CountDispatchContext value={dispatch}>
        {children}
      </CountDispatchContext>
    </CountStateContext>
  );
}

When to reach for this: When you have context that causes re-renders in components that only need part of the context value. Split state from actions, scope context to subtrees, and use selectors to read only what you need.

Working Example

import {
  createContext,
  use,
  useState,
  useMemo,
  useCallback,
  memo,
  type ReactNode,
} from "react";
 
// --- Split context: Theme state vs actions ---
 
interface ThemeState {
  mode: "light" | "dark";
  accentColor: string;
  fontSize: number;
}
 
interface ThemeActions {
  toggleMode: () => void;
  setAccentColor: (color: string) => void;
  setFontSize: (size: number) => void;
}
 
const ThemeStateContext = createContext<ThemeState>({
  mode: "light",
  accentColor: "#3b82f6",
  fontSize: 16,
});
 
const ThemeActionsContext = createContext<ThemeActions | null>(null);
 
function ThemeProvider({ children }: { children: ReactNode }) {
  const [state, setState] = useState<ThemeState>({
    mode: "light",
    accentColor: "#3b82f6",
    fontSize: 16,
  });
 
  // Stable actions object — never causes re-renders in action-only consumers
  const actions = useMemo<ThemeActions>(
    () => ({
      toggleMode: () =>
        setState((s) => ({
          ...s,
          mode: s.mode === "light" ? "dark" : "light",
        })),
      setAccentColor: (color) =>
        setState((s) => ({ ...s, accentColor: color })),
      setFontSize: (size) =>
        setState((s) => ({ ...s, fontSize: size })),
    }),
    []
  );
 
  return (
    <ThemeStateContext value={state}>
      <ThemeActionsContext value={actions}>
        {children}
      </ThemeActionsContext>
    </ThemeStateContext>
  );
}
 
// Custom hooks with safety checks
function useThemeState() {
  return use(ThemeStateContext);
}
 
function useThemeActions() {
  const actions = use(ThemeActionsContext);
  if (!actions) throw new Error("useThemeActions must be within ThemeProvider");
  return actions;
}
 
// --- Components demonstrating selective consumption ---
 
// Only re-renders when theme state changes
const ThemeIndicator = memo(function ThemeIndicator() {
  const { mode, accentColor } = useThemeState();
  console.log("ThemeIndicator rendered");
  return (
    <div className="flex items-center gap-2">
      <div
        className="w-4 h-4 rounded-full"
        style={{ backgroundColor: accentColor }}
      />
      <span>{mode} mode</span>
    </div>
  );
});
 
// Only re-renders when actions context changes (never, because it's memoized)
const ThemeToggleButton = memo(function ThemeToggleButton() {
  const { toggleMode } = useThemeActions();
  console.log("ThemeToggleButton rendered");
  return (
    <button onClick={toggleMode} className="px-3 py-1 border rounded">
      Toggle Theme
    </button>
  );
});
 
// --- Scoped context pattern ---
 
interface NotificationContextValue {
  notifications: Notification[];
  add: (message: string) => void;
  dismiss: (id: string) => void;
}
 
const NotificationContext = createContext<NotificationContextValue | null>(null);
 
function useNotifications() {
  const ctx = use(NotificationContext);
  if (!ctx) throw new Error("useNotifications must be within NotificationProvider");
  return ctx;
}
 
interface Notification {
  id: string;
  message: string;
}
 
function NotificationProvider({ children }: { children: ReactNode }) {
  const [notifications, setNotifications] = useState<Notification[]>([]);
 
  const add = useCallback((message: string) => {
    const id = crypto.randomUUID();
    setNotifications((prev) => [...prev, { id, message }]);
    setTimeout(() => {
      setNotifications((prev) => prev.filter((n) => n.id !== id));
    }, 5000);
  }, []);
 
  const dismiss = useCallback((id: string) => {
    setNotifications((prev) => prev.filter((n) => n.id !== id));
  }, []);
 
  const value = useMemo(
    () => ({ notifications, add, dismiss }),
    [notifications, add, dismiss]
  );
 
  return (
    <NotificationContext value={value}>
      {children}
    </NotificationContext>
  );
}
 
// --- Full app layout ---
function App() {
  return (
    <ThemeProvider>
      <NotificationProvider>
        <header className="flex justify-between p-4 border-b">
          <ThemeIndicator />
          <ThemeToggleButton />
        </header>
        <main className="p-6">
          <ContentArea />
        </main>
      </NotificationProvider>
    </ThemeProvider>
  );
}

What this demonstrates:

  • Split context: ThemeStateContext and ThemeActionsContext are separate
  • Stable actions via useMemo — action-only consumers never re-render
  • memo on leaf components to prevent re-renders from parent renders
  • Scoped notification context with auto-dismiss
  • Custom hooks with helpful error messages for missing providers

Deep Dive

How It Works

  • React context re-renders every consumer whenever the context value changes (by reference).
  • Splitting state and actions into separate contexts means action-only consumers (like buttons) don't re-render when state changes.
  • useMemo on the actions object ensures its reference never changes, making the actions context stable.
  • For the state context, useMemo on the value object is only useful if you have multiple state fields and want to prevent re-renders when unrelated fields change — but since the object is recreated when any field changes, it is most effective with split contexts per domain.
  • React 19 use() can read context conditionally (inside if-statements), unlike useContext.

Parameters & Return Values

PatternWhat It Solves
Split state/actionsAction-only consumers (buttons, forms) don't re-render on state changes
Memoized value objectPrevents re-renders when the object reference would change but contents are the same
Scoped providerContext only available to subtree that needs it
Custom hook with errorCatches missing provider bugs at development time
Default value on createContextAllows using context without a provider (useful for theme defaults)

Variations

Selector pattern with external store — subscribe to just the piece you need:

import { useSyncExternalStore } from "react";
 
// Using useSyncExternalStore for selector-based reads
function useStoreSelector<T, S>(store: Store<T>, selector: (state: T) => S): S {
  return useSyncExternalStore(
    store.subscribe,
    () => selector(store.getSnapshot()),
    () => selector(store.getServerSnapshot())
  );
}
 
// Only re-renders when `user.name` changes
function UserName() {
  const name = useStoreSelector(appStore, (s) => s.user.name);
  return <span>{name}</span>;
}

Context with reducer — for complex state transitions:

const TodoDispatchContext = createContext<React.Dispatch<TodoAction> | null>(null);
const TodoStateContext = createContext<TodoState>({ items: [] });
 
function TodoProvider({ children }: { children: ReactNode }) {
  const [state, dispatch] = useReducer(todoReducer, { items: [] });
 
  return (
    <TodoStateContext value={state}>
      <TodoDispatchContext value={dispatch}>
        {children}
      </TodoDispatchContext>
    </TodoStateContext>
  );
}

TypeScript Notes

  • Provide a sensible default to createContext<T>(defaultValue) when the context can work without a provider.
  • Use createContext<T | null>(null) when a provider is required, and check for null in the custom hook.
  • Type the custom hook return as non-null (ThemeActions not ThemeActions | null) after the null check.
  • Export context types so consumers can type their own abstractions.

Gotchas

  • Single context with mixed state and actions — Every state change re-renders every consumer, even those that only call actions. Fix: Split into separate state and action contexts.

  • Unstable context value — Creating a new object literal in the provider's render (value={{ a, b }}) causes every consumer to re-render. Fix: Use useMemo to stabilize the value reference.

  • Provider too high in the tree — Placing a frequently-changing provider at the app root re-renders the entire tree of consumers. Fix: Scope providers to the smallest subtree that needs them.

  • Over-splitting context — Creating dozens of tiny contexts adds complexity and provider nesting. Fix: Split by update frequency (things that change together should live together). Use Zustand or Jotai for fine-grained subscriptions.

  • Default context values hiding bugs — A meaningful default value means the context works without a provider, which can mask a missing provider. Fix: Use null default + custom hook with throw for required providers.

Alternatives

ApproachTrade-off
Split contextZero dependencies; manual splitting effort
ZustandAutomatic selectors, no providers; extra dependency
JotaiAtomic state, fine-grained re-renders; different mental model
Redux + useSelectorMature ecosystem, time-travel debug; boilerplate
useSyncExternalStoreWorks with any external store; lower-level API
Signals (future)Fine-grained reactivity; not yet in React

FAQs

Why does putting state and actions in a single context cause performance problems?
  • Every state change re-renders every consumer, even those that only call actions (like buttons).
  • Consumers that never read state still re-render because the context value reference changes.
  • Fix: split into separate state and action contexts.
How does splitting state and actions into separate contexts help performance?
  • Action-only consumers (buttons, forms) subscribe only to the actions context.
  • Since the actions object is memoized with useMemo(() => actions, []), its reference never changes.
  • Action-only consumers never re-render when state changes.
What does useMemo on the actions object accomplish in a context provider?
const actions = useMemo<ThemeActions>(
  () => ({
    toggleMode: () => setState((s) => ({ ...s, mode: s.mode === "light" ? "dark" : "light" })),
    setAccentColor: (color) => setState((s) => ({ ...s, accentColor: color })),
  }),
  []
);
  • It creates a stable reference that never changes across renders.
  • Consumers of the actions context never re-render because the reference stays the same.
When should you scope a context provider to a subtree instead of the app root?
  • When only a portion of the app needs the context (e.g., a notification system for one page).
  • When the context value changes frequently and would cause re-renders in unrelated components.
  • Scoping reduces the blast radius of context updates.
How does use() in React 19 differ from useContext()?
  • use() can be called conditionally (inside if-statements), unlike useContext().
  • use() also works with promises for Suspense-based data fetching.
  • useContext() still works in React 19 but use() is the more flexible alternative.
Gotcha: Why does value={{ a, b }} in a provider cause unnecessary re-renders?
  • Creating a new object literal in the render creates a new reference every render.
  • React checks context value by reference, so every consumer re-renders even if a and b have not changed.
  • Fix: memoize the value with useMemo or split into separate contexts.
Gotcha: How can a default context value hide a missing provider bug?
  • If you provide a meaningful default to createContext(defaultValue), components work without a provider.
  • This can mask a missing provider in the tree, leading to subtle bugs where components use stale defaults.
  • Fix: use createContext<T | null>(null) and throw in the custom hook when context is null.
How do you type a context that requires a provider in TypeScript?
const MyContext = createContext<MyState | null>(null);
 
function useMyContext(): MyState {
  const ctx = use(MyContext);
  if (!ctx) throw new Error("useMyContext must be within MyProvider");
  return ctx;
}
  • Use | null default and check for null in the custom hook.
  • The hook return type is MyState (non-null) after the check.
What is the selector pattern with useSyncExternalStore and when is it useful?
  • useSyncExternalStore subscribes to an external store and reads a selected slice of state.
  • Only the selected value triggers re-renders, giving fine-grained subscription without context splitting.
  • Useful when you need selector-based reads without adopting a full state management library.
How do you combine context with useReducer for complex state transitions?
const TodoStateContext = createContext<TodoState>({ items: [] });
const TodoDispatchContext = createContext<React.Dispatch<TodoAction> | null>(null);
 
function TodoProvider({ children }: { children: ReactNode }) {
  const [state, dispatch] = useReducer(todoReducer, { items: [] });
  return (
    <TodoStateContext value={state}>
      <TodoDispatchContext value={dispatch}>{children}</TodoDispatchContext>
    </TodoStateContext>
  );
}
  • Split state and dispatch into separate contexts.
  • dispatch is stable (React guarantees it), so dispatch-only consumers never re-render.
When should you use Zustand or Jotai instead of React context?
  • When you need fine-grained subscriptions (select a single field without re-rendering on unrelated changes).
  • When you have many frequently-changing values that would require excessive context splitting.
  • When you want a simpler API without provider nesting.
  • Performance — Memoization and re-render optimization techniques
  • Compound Components — Context is the backbone of compound components
  • Composition — Composition reduces the need for context in many cases