Skip to content
React rc advanced 5 min read

Advanced Context

React Context solves prop drilling, but using it well in a real application takes more than wrapping a tree in a Provider. The patterns on this page turn context from a quick fix into a robust, type-safe, and performant building block. You will learn how to package a context behind a custom hook, how to fail loudly when a provider is missing, how to compose multiple contexts, and how to keep re-renders under control with split contexts and selectors.

The provider plus custom hook module pattern

A raw context object leaks implementation details: every consumer has to import the context, call useContext, and remember to read it correctly. A cleaner approach is to keep the context private to a single module and expose only a provider component and a custom hook. Consumers never touch createContext directly.

// theme-context.jsx
import { createContext, useContext, useMemo, useState } from "react";

const ThemeContext = createContext(null);

export function ThemeProvider({ children }) {
  const [theme, setTheme] = useState("light");

  const value = useMemo(
    () => ({ theme, toggle: () => setTheme((t) => (t === "light" ? "dark" : "light")) }),
    [theme]
  );

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

export function useTheme() {
  return useContext(ThemeContext);
}

Wrapping the value in useMemo matters: without it, a brand-new object is created on every render of ThemeProvider, forcing every consumer to re-render even when nothing changed.

Require a provider, fail loudly

The default value passed to createContext is a footgun. If a component reads the context outside of any provider, it silently receives that default, often producing confusing runtime behavior far from the real cause. A better default is null, paired with a hook that throws a clear error.

export function useTheme() {
  const ctx = useContext(ThemeContext);
  if (ctx === null) {
    throw new Error("useTheme must be used within a <ThemeProvider>");
  }
  return ctx;
}

Throwing in the hook turns a vague “cannot read property of null” deep in your render into an actionable message that names the missing provider.

In TypeScript this also narrows the type, so callers get a non-nullable value without extra guards.

interface ThemeValue {
  theme: "light" | "dark";
  toggle: () => void;
}

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

export function useTheme(): ThemeValue {
  const ctx = useContext(ThemeContext);
  if (!ctx) throw new Error("useTheme must be used within a <ThemeProvider>");
  return ctx;
}

Multiple and nested contexts

Most apps have several independent concerns: theme, auth, locale, a toast queue. Each deserves its own context so an update to one does not disturb consumers of another. Composing many providers, however, leads to a “pyramid of doom” in the root.

function AppProviders({ children }) {
  return (
    <ThemeProvider>
      <AuthProvider>
        <LocaleProvider>{children}</LocaleProvider>
      </AuthProvider>
    </ThemeProvider>
  );
}

A small helper flattens that nesting and keeps the root readable as the list grows.

function composeProviders(...providers) {
  return ({ children }) =>
    providers.reduceRight((acc, Provider) => <Provider>{acc}</Provider>, children);
}

const AppProviders = composeProviders(ThemeProvider, AuthProvider, LocaleProvider);

Performance with split contexts

When a single context holds both state and the functions that update it, any state change re-renders every consumer, including those that only need the updater. Splitting the value into a state context and a dispatch context lets components subscribe to just the part they use.

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

const CountStateContext = createContext(null);
const CountDispatchContext = createContext(null);

function reducer(state, action) {
  switch (action.type) {
    case "inc": return { count: state.count + 1 };
    case "dec": return { count: state.count - 1 };
    default: throw new Error(`Unknown action ${action.type}`);
  }
}

export function CountProvider({ children }) {
  const [state, dispatch] = useReducer(reducer, { count: 0 });
  return (
    <CountStateContext.Provider value={state}>
      <CountDispatchContext.Provider value={dispatch}>
        {children}
      </CountDispatchContext.Provider>
    </CountStateContext.Provider>
  );
}

export const useCount = () => useContext(CountStateContext);
export const useCountDispatch = () => useContext(CountDispatchContext);

A button that only dispatches actions reads useCountDispatch. Because dispatch is stable for the lifetime of the component, that button never re-renders when the count changes.

Selectors for fine-grained reads

Even a split context re-renders all state consumers when any field changes. A selector pattern lets a component subscribe to a derived slice and re-render only when that slice changes. The lightweight useSyncExternalStore-based approach below avoids extra libraries.

import { useRef, useCallback, useSyncExternalStore } from "react";

function createStore(initial) {
  let state = initial;
  const listeners = new Set();
  return {
    get: () => state,
    set: (next) => {
      state = typeof next === "function" ? next(state) : next;
      listeners.forEach((l) => l());
    },
    subscribe: (l) => (listeners.add(l), () => listeners.delete(l)),
  };
}

export function useSelector(store, selector) {
  return useSyncExternalStore(store.subscribe, () => selector(store.get()));
}

A component calling useSelector(store, (s) => s.user.name) re-renders only when the name changes, ignoring unrelated updates to the same store.

Output:

[render] UserName   // only fires when s.user.name changes
[render] CartBadge  // only fires when s.cart.length changes

Comparing the approaches

PatternBest forRe-render scope
Single contextSmall, rarely changing valuesAll consumers
Custom hook + providerAny reusable contextAll consumers
Split state/dispatchFrequent updates with stable dispatchState consumers only
Selector storeLarge state, granular readsComponents whose slice changed

Best Practices

  • Keep createContext private to a module and export a provider plus a useX hook so consumers never import the context object.
  • Default the context to null and throw from the hook when no provider is found, giving a precise error message.
  • Memoize the provided value with useMemo (or split out a stable dispatch) to avoid re-rendering every consumer.
  • Split unrelated concerns into separate contexts instead of one mega-context.
  • Separate state from updaters so action-only components stay isolated from state changes.
  • Reach for a selector pattern or an external store once a single context drives many components with differing data needs.
Last updated June 14, 2026
Was this helpful?