Skip to main content
Technology & EngineeringReact Patterns226 lines

Context Patterns

React Context patterns for efficient state sharing, provider composition, and avoiding unnecessary re-renders

Quick Summary18 lines
You are an expert in React Context patterns for building React applications.

## Key Points

- **Provider/Consumer Model**: A Provider component supplies a value; any descendant can consume it with `useContext`.
- **Separation of State and Dispatch**: Split read-only state and update functions into separate contexts to avoid re-rendering components that only dispatch.
- **Context Composition**: Combine multiple small, focused contexts rather than one monolithic global context.
- **Default Values**: `createContext` accepts a default used when no Provider is found. Prefer `null` and throw in the hook for safety.
- Create one context per concern: theme, auth, locale, cart — not one giant "AppContext."
- Always wrap `useContext` in a custom hook that throws if the provider is missing.
- Memoize the context value object with `useMemo` to prevent unnecessary re-renders.
- Split state and dispatch contexts so components that only trigger actions do not re-render on state changes.
- Place providers as close to the consumers as possible — not everything belongs at the app root.
- **Inline object as value**: Writing `<Ctx.Provider value={{ user, theme }}>` creates a new object every render, forcing all consumers to re-render.
- **One giant context**: Updating any field re-renders every consumer. Split into focused contexts.
- **Relying on default values for logic**: Default context values are only used when no provider is found — they should signal a bug, not serve as production defaults.
skilldb get react-patterns-skills/Context PatternsFull skill: 226 lines
Paste into your CLAUDE.md or agent config

Context Patterns — React Patterns

You are an expert in React Context patterns for building React applications.

Overview

React Context provides a way to pass data through the component tree without prop drilling. When used thoughtfully, it powers theme systems, authentication state, locale preferences, and feature flags. However, naive context usage causes performance problems because every consumer re-renders when the context value changes. This skill covers patterns that make context efficient and maintainable.

Core Philosophy

Context is dependency injection for React. It solves a specific problem: getting a value from point A to point B in a component tree without threading it through every component in between. When you reach for context, you should be able to articulate what value you are injecting and why prop drilling is genuinely painful for that particular case. If the value only needs to travel two levels, props are simpler and more explicit.

The key insight is that context is optimized for low-frequency, wide-reaching state — themes, authentication, locale, feature flags. These values change rarely and are consumed by many components scattered across the tree. Context is not a general-purpose state manager. When you put high-frequency state (form input values, animation frames, mouse positions) into context, every consumer re-renders on every change, and no amount of memoization fully solves the problem. For those cases, external stores with useSyncExternalStore or dedicated state libraries are the right tool.

Good context architecture follows the single-responsibility principle. One context per concern, one provider per context, and custom hooks that encapsulate the useContext call with a safety check. When you find yourself creating a context with ten fields that serve different parts of the app, you have built a global variable with extra steps. Split it. Small, focused contexts compose better, perform better, and are easier to test in isolation.

Core Concepts

  • Provider/Consumer Model: A Provider component supplies a value; any descendant can consume it with useContext.
  • Separation of State and Dispatch: Split read-only state and update functions into separate contexts to avoid re-rendering components that only dispatch.
  • Context Composition: Combine multiple small, focused contexts rather than one monolithic global context.
  • Default Values: createContext accepts a default used when no Provider is found. Prefer null and throw in the hook for safety.

Implementation Patterns

Typed Context with Safety Hook

import { createContext, useContext, ReactNode } from "react";

interface AuthState {
  user: { id: string; name: string } | null;
  isAuthenticated: boolean;
}

const AuthContext = createContext<AuthState | null>(null);

export function useAuth(): AuthState {
  const ctx = useContext(AuthContext);
  if (ctx === null) {
    throw new Error("useAuth must be used within an AuthProvider");
  }
  return ctx;
}

export function AuthProvider({ children, user }: { children: ReactNode; user: AuthState["user"] }) {
  const value: AuthState = { user, isAuthenticated: user !== null };
  return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
}

Splitting State and Dispatch

import { createContext, useContext, useReducer, ReactNode, Dispatch } from "react";

interface CartState {
  items: Array<{ id: string; name: string; qty: number }>;
  total: number;
}

type CartAction =
  | { type: "ADD_ITEM"; payload: { id: string; name: string; price: number } }
  | { type: "REMOVE_ITEM"; payload: { id: string } }
  | { type: "CLEAR" };

const CartStateContext = createContext<CartState | null>(null);
const CartDispatchContext = createContext<Dispatch<CartAction> | null>(null);

function cartReducer(state: CartState, action: CartAction): CartState {
  switch (action.type) {
    case "ADD_ITEM": {
      const existing = state.items.find((i) => i.id === action.payload.id);
      if (existing) {
        return {
          ...state,
          items: state.items.map((i) =>
            i.id === action.payload.id ? { ...i, qty: i.qty + 1 } : i
          ),
          total: state.total + action.payload.price,
        };
      }
      return {
        items: [...state.items, { id: action.payload.id, name: action.payload.name, qty: 1 }],
        total: state.total + action.payload.price,
      };
    }
    case "REMOVE_ITEM":
      return {
        ...state,
        items: state.items.filter((i) => i.id !== action.payload.id),
      };
    case "CLEAR":
      return { items: [], total: 0 };
    default:
      return state;
  }
}

export function CartProvider({ children }: { children: ReactNode }) {
  const [state, dispatch] = useReducer(cartReducer, { items: [], total: 0 });
  return (
    <CartStateContext.Provider value={state}>
      <CartDispatchContext.Provider value={dispatch}>
        {children}
      </CartDispatchContext.Provider>
    </CartStateContext.Provider>
  );
}

export function useCartState() {
  const ctx = useContext(CartStateContext);
  if (!ctx) throw new Error("useCartState must be inside CartProvider");
  return ctx;
}

export function useCartDispatch() {
  const ctx = useContext(CartDispatchContext);
  if (!ctx) throw new Error("useCartDispatch must be inside CartProvider");
  return ctx;
}

Provider Composition Helper

import { ReactNode, ComponentType } from "react";

type ProviderWithProps = [ComponentType<any>, Record<string, any>?];

function ComposeProviders({
  providers,
  children,
}: {
  providers: ProviderWithProps[];
  children: ReactNode;
}) {
  return providers.reduceRight(
    (acc, [Provider, props]) => <Provider {...props}>{acc}</Provider>,
    children
  ) as JSX.Element;
}

// Usage — avoids deeply nested JSX
function App() {
  return (
    <ComposeProviders
      providers={[
        [ThemeProvider, { defaultTheme: "light" }],
        [AuthProvider, { user: currentUser }],
        [CartProvider],
        [NotificationProvider],
      ]}
    >
      <MainApp />
    </ComposeProviders>
  );
}

Context with useMemo to Prevent Re-renders

import { createContext, useContext, useState, useMemo, ReactNode } from "react";

interface ThemeContextValue {
  mode: "light" | "dark";
  toggleMode: () => void;
}

const ThemeContext = createContext<ThemeContextValue | null>(null);

export function ThemeProvider({ children }: { children: ReactNode }) {
  const [mode, setMode] = useState<"light" | "dark">("light");

  const value = useMemo<ThemeContextValue>(
    () => ({
      mode,
      toggleMode: () => setMode((m) => (m === "light" ? "dark" : "light")),
    }),
    [mode]
  );

  return <ThemeContext.Provider value={value}>{children}</ThemeContext.Provider>;
}

export function useTheme() {
  const ctx = useContext(ThemeContext);
  if (!ctx) throw new Error("useTheme must be inside ThemeProvider");
  return ctx;
}

Best Practices

  • Create one context per concern: theme, auth, locale, cart — not one giant "AppContext."
  • Always wrap useContext in a custom hook that throws if the provider is missing.
  • Memoize the context value object with useMemo to prevent unnecessary re-renders.
  • Split state and dispatch contexts so components that only trigger actions do not re-render on state changes.
  • Place providers as close to the consumers as possible — not everything belongs at the app root.

Common Pitfalls

  • Inline object as value: Writing <Ctx.Provider value={{ user, theme }}> creates a new object every render, forcing all consumers to re-render.
  • One giant context: Updating any field re-renders every consumer. Split into focused contexts.
  • Using context for high-frequency updates: Context is not optimized for values that change on every frame (e.g., mouse position). Use external stores (useSyncExternalStore) or signals for high-frequency state.
  • Relying on default values for logic: Default context values are only used when no provider is found — they should signal a bug, not serve as production defaults.
  • Deep provider nesting without composition: Manually nesting 10+ providers makes the tree unreadable. Use a composition helper.

Anti-Patterns

  • The "AppContext" monolith: Putting authentication, theme, locale, cart, notifications, and feature flags into a single context. Any update to any field triggers re-renders in every consumer. This creates invisible performance cliffs that are difficult to diagnose because the re-render cause is far from the affected component.

  • Context as a prop-drilling shortcut for parent-child communication: Using context to pass data one or two levels deep instead of props. This adds indirection, makes data flow harder to trace, and prevents the child from being reused outside that specific provider hierarchy.

  • Mutable refs in context to "avoid re-renders": Storing a mutable ref in context and mutating it directly so consumers do not re-render. This breaks React's rendering model — consumers read stale data and the UI falls out of sync with state.

  • Default values that serve as production fallbacks: Setting createContext({ user: null, theme: "light" }) and relying on those defaults when no provider is present. Default values should signal a missing provider (use null and throw in the hook), not silently degrade into a broken state.

  • Provider placement at the app root "just in case": Wrapping the entire application in every provider regardless of where consumers live. This inflates the root component, makes testing harder, and causes unnecessary work. Place providers as close to their consumers as possible.

Install this skill directly: skilldb add react-patterns-skills

Get CLI access →