Context: Providers, Performance, and When Not to Use It
Master createContext/useContext for sharing state across a component tree, and learn to reason about context performance, splitting, and its place relative to props and external state stores.
Context solves prop drilling by letting any descendant of a Provider read a value directly, but it is not a general-purpose state management system — it's a distribution mechanism. Used well, it eliminates painful prop chains for genuinely cross-cutting values like theme, locale, and the current user. Used carelessly, it becomes a source of unnecessary re-renders across large swaths of the app. This lesson covers both the mechanics and the judgment calls.
createContext, Provider, and useContext
createContext<T>(defaultValue) returns a Context object with a .Provider component. Any component rendered inside that Provider, at any depth, can call useContext(MyContext) to read the value the nearest enclosing Provider currently holds — 'nearest enclosing' matters, because nested Providers of the same context shadow outer ones, just like nested scopes in JavaScript.
The defaultValue argument to createContext is only used by a component that calls useContext without any Provider above it in the tree at all. In practice this is usually a sign of a missing Provider (a bug), so it's common to type the context as T | undefined, default it to undefined, and have a wrapping custom hook throw a clear error if it comes back undefined — turning a silent wrong-value bug into a loud, immediate one during development.
Updating context is just calling the same setState (or dispatch) that produces the value passed into the Provider's value prop — Context itself has no special update API. The Provider is a normal React component; changing its value prop between renders is what propagates the new value to every consuming component.
Context updates and performance
When a Provider's value changes, React re-renders every component that calls useContext on that context, regardless of how deep in the tree it is — this is by design, and it's also the main performance trap. If a context bundles many unrelated pieces of state into one object (say, { user, theme, notifications, cart }), then updating any single field causes every consumer of the whole context to re-render, even ones that only cared about theme.
The standard fix is splitting contexts by how often each piece of data changes and by who actually needs it: a UserContext, a ThemeContext, and a CartContext rendered as three separate Providers means a cart update only re-renders CartContext consumers, leaving ThemeContext and UserContext consumers untouched. This is a much bigger performance win than it sounds — 'one big AppContext' is one of the most common causes of unexplained re-render storms in React apps that lean heavily on Context.
A related, more advanced technique is a selector pattern: instead of exposing the raw context value directly, expose a hook like useUser(selector) that internally uses useContext plus useMemo (or a small library like use-context-selector) so a component only re-renders when the specific slice it selected actually changes, not whenever any field on the context object changes. Plain React Context has no built-in selector mechanism — this pattern requires either careful memoization or a dedicated library.
Context vs. props vs. state management libraries
Props remain the right default for passing data down one or two levels — they're explicit, traceable with a simple 'find references' search, and don't add any re-render surface beyond the components that actually receive them. Reach for Context specifically when a value needs to reach many components at unpredictable depths, and passing it as a prop through every intermediate layer would mean those intermediate components take a prop they never use themselves, purely to relay it further down.
Context is not a replacement for a dedicated state management library (like Redux, Zustand, or Jotai) for large, frequently-updated application state. Those libraries are built around external stores with fine-grained subscriptions — a component subscribes to exactly the slice of state it reads, and only that component re-renders when that slice changes, without needing to manually split state into a dozen separate Providers. Context's re-render-everything-on-any-change behavior makes it a reasonable fit for state that changes rarely (auth, theme, locale, feature flags) and a poor fit for state that changes frequently and is read by many components (like a large normalized data cache).
Briefly, on external stores: React 18 added useSyncExternalStore, the hook that state management libraries use under the hood to subscribe a component to an external (non-React) store safely — 'safely' meaning it accounts for concurrent rendering correctly, avoiding a subtle class of bugs called tearing, where different parts of the UI could otherwise render with inconsistent snapshots of the same store during a single update. You won't usually call useSyncExternalStore directly in application code, but it's worth knowing it exists as the reason library-based state (Redux, Zustand) stays consistent across concurrent renders, in a way a naive external subscription would not automatically guarantee.
A typed context with a validating custom hook
useMemo around the Provider's value object avoids creating a brand-new object identity on every render of ThemeProvider, which would otherwise force every consumer to re-render even when theme itself hasn't changed. The useTheme wrapper hook throws immediately if called outside a ThemeProvider, converting a silent 'undefined' bug into a clear error at the exact call site that's missing its Provider.
type Theme = 'light' | 'dark';
type ThemeContextValue = { theme: Theme; toggleTheme: () => void };
const ThemeContext = createContext<ThemeContextValue | undefined>(undefined);
function ThemeProvider({ children }: { children: React.ReactNode }) {
const [theme, setTheme] = useState<Theme>('light');
const toggleTheme = useCallback(
() => setTheme((t) => (t === 'light' ? 'dark' : 'light')),
[]
);
const value = useMemo(() => ({ theme, toggleTheme }), [theme, toggleTheme]);
return <ThemeContext.Provider value={value}>{children}</ThemeContext.Provider>;
}
function useTheme(): ThemeContextValue {
const ctx = useContext(ThemeContext);
if (!ctx) throw new Error('useTheme must be used within a ThemeProvider');
return ctx;
}Code Examples
Components that only read theme now subscribe to ThemeContext alone, so a cart update (which changes CartContext's value) no longer triggers their re-render — the two concerns are isolated.
const ThemeContext = ...; const CartContext = ...; // split, not combined
// Before: one context, updating cart re-renders every consumer, including theme readers
const AppContext = createContext<{ theme: Theme; cart: CartItem[] } | undefined>(undefined);
// After: split by update frequency / concern
const ThemeContext = createContext<Theme | undefined>(undefined);
const CartContext = createContext<CartItem[] | undefined>(undefined);
function AppProviders({ children }: { children: React.ReactNode }) {
return (
<ThemeProvider>
<CartProvider>{children}</CartProvider>
</ThemeProvider>
);
}Context skips prop drilling but re-renders every consumer on change
No intermediate component needs to relay the value, but every consumer re-renders on any context value change, which is why splitting contexts by concern matters.
Provider (value=v1)
|
... any depth, any number of layers ...
|
useContext -> v1 useContext -> v1 useContext -> v1
(Consumer A) (Consumer B) (Consumer C)
Provider value changes to v2:
ALL of Consumer A, B, C re-render — even ones that only read one field of v2Important Rules
Split contexts by how often their contents change and by who reads them, rather than bundling unrelated state into one Provider.
Wrap the Provider's value in useMemo (and its setters in useCallback) so a re-render of the Provider component doesn't itself force every consumer to re-render.
Wrap useContext in a small custom hook that throws when the Provider is missing, instead of letting undefined propagate silently.
Use Context for cross-cutting, infrequently-changing values (theme, locale, auth); use a dedicated state library or lift state for frequently-updated, widely-read application data.
Default to props for one or two levels of depth — only reach for Context once prop drilling genuinely becomes the more painful option.
Where Context is the right tool
Theme (light/dark/system), the authenticated user and auth actions, i18n/locale settings, and feature flags are the textbook real-world uses of Context — values that legitimately belong to the whole app, change infrequently, and would otherwise require prop drilling through many unrelated layers. Large, frequently updated data (a live list of chat messages, a big normalized entity cache) is usually better served by a dedicated state library or server-state tool, precisely because of the re-render-everything behavior described above.
function ThemeProvider({ children }: { children: React.ReactNode }) { const [theme, setTheme] = useState<Theme>('light'); return ( <ThemeContext.Provider value={{ theme, setTheme }}> {children} </ThemeContext.Provider>); } function ThemeProvider({ children }: { children: React.ReactNode }) { const [theme, setTheme] = useState<Theme>('light'); const value = useMemo(() => ({ theme, setTheme }), [theme]); return <ThemeContext.Provider value={value}>{children}</ThemeContext.Provider>; }
Inlining a new object literal as the value prop creates a new object identity on every single render of ThemeProvider, even when theme hasn't changed — this forces every consumer to re-render every time, defeating any benefit of memoizing consumers downstream.
[multiple-choice] Why does bundling unrelated state into a single context hurt performance? A. Context objects are limited to one field internally B. Every consumer of a context re-renders whenever that context's value changes, even if it only reads one unrelated field of it C. createContext can only be called once per file D. useContext is inherently slower than useState
B. Every consumer of a context re-renders whenever that context's value changes, even if it only reads one unrelated field of it — React re-renders every component that calls useContext on a given context whenever that context's value changes as a whole — splitting by concern limits the blast radius of each update.
[multiple-choice] What does wrapping useContext in a custom hook that throws on a missing Provider primarily protect against? A. Slow network requests B. A silent undefined value leaking deep into the tree instead of failing loudly at the point of use C. Memory leaks from unused Providers D. Type errors at compile time only
B. A silent undefined value leaking deep into the tree instead of failing loudly at the point of use — Without the check, a missing Provider produces undefined from useContext, which can propagate silently until it causes a confusing crash or wrong behavior far from its real cause; throwing immediately makes the bug obvious.
[true-false] True or false: React Context includes a built-in selector mechanism so a component only re-renders when the specific field it reads changes. A. True B. False
B. False — Plain Context has no built-in selector — every consumer re-renders on any change to the context value. Selector-like behavior requires manual splitting, memoization, or a dedicated library such as use-context-selector.
You have a single AppContext exposing { user, notifications }, where notifications update every few seconds via a WebSocket and user rarely changes. Components that only display the user's name are re-rendering on every notification. Describe the fix.
Split into UserContext (rarely updates) and NotificationsContext (updates frequently), each with its own Provider and memoized value — components reading only user now subscribe solely to UserContext and are unaffected by notification updates.
Split into two contexts by update frequency, and memoize each Provider's value.