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
| Pattern | Best for | Re-render scope |
|---|---|---|
| Single context | Small, rarely changing values | All consumers |
| Custom hook + provider | Any reusable context | All consumers |
| Split state/dispatch | Frequent updates with stable dispatch | State consumers only |
| Selector store | Large state, granular reads | Components whose slice changed |
Best Practices
- Keep
createContextprivate to a module and export a provider plus auseXhook so consumers never import the context object. - Default the context to
nulland throw from the hook when no provider is found, giving a precise error message. - Memoize the provided value with
useMemo(or split out a stabledispatch) 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.