Concept
The beginner framing: Context lets you share a value, a theme, the logged-in user, a language setting, with any component deep in the tree, without manually passing it down as a prop through every intermediate component that doesn't actually need it.
The precise mental model: createContext makes a container for a value; a <Context.Provider value={...}> makes that value available to every descendant; and useContext(Context) reads the value from the nearest enclosing Provider, skipping straight past any intermediate components entirely, regardless of what props those components do or don't accept.
const ThemeContext = createContext("light");
function App() {
const [theme, setTheme] = useState("light");
return (
<ThemeContext.Provider value={theme}>
<Toolbar /> {/* doesn't know or care about theme at all */}
</ThemeContext.Provider>
);
}
function Toolbar() {
return <ThemedButton />; // still doesn't touch theme
}
function ThemedButton() {
const theme = useContext(ThemeContext); // reads it directly, no drilling through Toolbar
return <button className={theme}>Click me</button>;
}Without Context, theme would have to be passed as a prop into Toolbar purely so it could forward it to ThemedButton, even though Toolbar itself never uses it. That's prop drilling, and it's the specific problem Context solves.
Every consumer re-renders when the Provider's value changes, full stop
This is the part that surprises people coming from Rendering Lifecycle: a context update bypasses React.memo entirely. Even a component wrapped in memo, with props that never change, will still re-render if it calls useContext on a context whose value just changed, because memo only compares props, and context isn't a prop.
Click "Toggle theme" a few times. Consumer A and Consumer B both re-render every time, even though they're wrapped in React.memo and their `label` prop never changes, because they call useContext(ThemeContext), and a context value change forces every consumer to re-render, completely bypassing memo's prop comparison. The Sibling box is also memo()'d but never calls useContext, it correctly stays frozen, since neither its props nor any context it subscribes to ever changed.
Try It
Predict what happens before checking the solution.
const CountContext = createContext(0);
const Display = React.memo(function Display() {
const count = useContext(CountContext);
return <div>{count}</div>;
});
function App() {
const [count, setCount] = useState(0);
return (
<CountContext.Provider value={count}>
<Display />
<button
Does wrapping Display in React.memo prevent it from re-rendering when the button is clicked?
Solution
No, Display re-renders on every click, despite being memoized and receiving zero props at all. React.memo's bailout only compares props; it has no visibility into context values a component reads via useContext. Since count (the context value) genuinely changes every click, Display re-renders every time, exactly as it should, memo simply isn't the mechanism that would prevent (or should prevent) this.
Implement It Yourself
Build a simplified context mechanism to see why "nearest Provider wins" for nested providers of the same context:
function createContext(defaultValue) {
const valueStack = [defaultValue]; // a STACK, last pushed = current value
return {
Provider({ value, renderChildren }) {
valueStack.push(value); // push this Provider's value on entry
const result = renderChildren();
valueStack.pop(); // pop it back off once we're done rendering its subtree
return result;
},
useContext() {
return valueStack[valueStack.length - 1]; // always read the TOP of the stack
},
};
}
const ThemeContext = createContext
Nested providers of the same context work exactly like this stack: the innermost Provider's value shadows every outer one for its own subtree, and popping back out restores whatever was active before it, "nearest Provider wins" isn't a special rule, it's just how a stack naturally behaves.
Under the Hood
The "nearest Provider wins, and it stops mattering once you exit that subtree" behavior is precisely a stack data structure from Data Structures, last pushed, first popped, exactly matching React's depth-first walk down and back up the tree during rendering. And Context deliberately bypassing React.memo's prop comparison connects directly back to Rendering Lifecycle: memo's bailout mechanism only ever inspects props, so anything a component reads through a different channel (context, an external store, a ref) is invisible to it by construction, not by omission.
Common Mistakes
1. Passing a fresh object literal as the Provider's value
function App() {
const [user, setUser] = useState(null);
const [theme, setTheme] = useState("light");
return (
// ❌ a NEW object every render, every consumer re-renders every time,
// even if only `theme` changed and a consumer only reads `user`
<AppContext.Provider value={{ user, theme, setUser, setTheme }}>
<Rest />
</AppContext.Provider>
);
}Context has no built-in way to tell "only part of the value object changed", it re-renders every consumer whenever the value reference changes, and an inline object literal is a new reference every render regardless of whether its contents actually differ. Wrap it in useMemo with the right dependencies, or split unrelated concerns into separate contexts entirely (see Best Practices).
2. Assuming a consumer only re-renders when the specific field it reads changes
const { theme } = useContext(AppContext); // only reads theme...
// ...but re-renders whenever ANY field in AppContext's value changes, including userContext re-renders are all-or-nothing at the Provider level, there's no field-level subscription built in. A consumer reading only one property of a large context value still re-renders on every change to any property in that value.
3. Reaching for Context as a general state-management replacement
Context is a distribution mechanism (getting a value to distant descendants), not a state-management library, it doesn't provide selective subscriptions, middleware, or devtools. For state that changes frequently and is read by many components with different, overlapping slices of interest, a dedicated state library with fine-grained subscriptions is usually a better fit than one large Context.
Best Practices
- Split unrelated concerns into separate contexts (
ThemeContext,AuthContext) rather than one largeAppContext, a component that only needs theme shouldn't re-render because auth state changed. - Memoize the value passed to a Provider with
useMemowhenever it's more than a single primitive, so re-renders of the Provider itself don't force every consumer to re-render due to a fresh object reference. - Reach for Context specifically to avoid prop drilling, for state that's only shared between a parent and one or two direct children, passing props directly is simpler and doesn't carry Context's all-consumers-re-render behavior.
- Keep Provider values relatively stable, Context is a poor fit for state that changes on every keystroke or every animation frame, precisely because every change re-renders every consumer.
Performance Tips
- Because Context updates bypass
React.memo, the usual "wrap it in memo" performance fix doesn't apply to context-driven re-renders, the actual levers are (1) splitting contexts by concern, and (2) memoizing the Provider's value. - For high-frequency values (scroll position, mouse coordinates, a value updating every animation frame), Context is generally the wrong tool, every consumer re-rendering on every update will visibly hurt performance. Prefer refs, a subscription-based store, or keeping that state as local as possible instead.
- If profiling shows a specific Context causing broad re-renders, splitting the value into multiple smaller contexts (one per independently-changing piece of data) directly reduces how many consumers are affected by any single update.
