Context and prop drilling
Context lets a component read a value from the nearest provider above it without threading props through every level. How lookup works, and why a new object value re-renders every reader.
After this lesson you can
- Recognise prop drilling and pick between composition and context to fix it
- Predict which provider value a component reads, including the default
- Explain why an inline object as a provider value re-renders every consumer, and fix it
Prop drilling is passing a prop through components that do not use it,
only so a deeply nested one can. Three levels of theme={theme} is
annoying; it also couples every intermediate component to data it does not
care about.
There are two fixes, and the first one is often overlooked:
- Composition. If a middle component only forwards something, let it
accept
childrenand have the top component render the deep one directly:<Layout><Avatar user={user} /></Layout>. No intermediate component touchesuser. - Context. For values many components at different depths need — the theme, the signed-in user, the locale — publish the value once and let any descendant read it.
const ThemeContext = createContext("light"); // the default
function App() {
return (
<ThemeContext value="dark"> {/* React 19 */}
<Toolbar />
</ThemeContext>
);
}
function Button() {
const theme = useContext(ThemeContext); // "dark"
return <button className={theme}>OK</button>;
}
In React 19 the context object itself is the provider (<ThemeContext value=…>); <ThemeContext.Provider> still works. React 19 also adds the
use API: use(ThemeContext) reads context like useContext, but unlike
a hook it may be called inside a condition or a loop (still only while a
component or hook is rendering).
How a component finds its value
useContext looks up the tree for the nearest provider of that
context and returns its value. Providers nest; the closest one wins, and
it only covers its own subtree. The default passed to createContext is
used only when there is no provider above at all — a provider whose value
is null or undefined still provides that value.
const ThemeContext = { defaultValue: "light" }; // Walk the tree with a stack of active providers, like React does.// A node's own read looks above it; its provider covers its children.function render(node, stack, out) { if (node.reads) { out.push(`${node.name}: ${stack.length ? stack[stack.length - 1] : ThemeContext.defaultValue}`); } const pushed = node.provides !== undefined; if (pushed) stack.push(node.provides); for (const child of node.children ?? []) render(child, stack, out); if (pushed) stack.pop(); return out;} export function run() { const tree = { name: "App", children: [ { name: "Header", reads: true }, { name: "DarkSection", provides: "dark", children: [ { name: "Button", reads: true }, { name: "Inverted", provides: "light", children: [{ name: "Badge", reads: true }] }, { name: "Nothing", provides: null, children: [{ name: "Icon", reads: true }] }, ], }, { name: "Footer", reads: true }, ], }; return render(tree, [], []);}When consumers re-render
When a provider renders with a value that is different by Object.is,
every component reading that context re-renders — even if a parent in
between is wrapped in memo. The trap is an inline object:
<AuthContext value={{ user, logout }}> {/* a new object every render */}
Every render of the component holding the provider creates a new object,
so every consumer re-renders, whether user changed or not:
export function run() { const user = { name: "Ada" }; const logout = () => {}; let consumerRenders = { inline: 0, memoised: 0 }; let prevInline, prevMemo; let memoCache = null; for (let render = 0; render < 3; render++) { // Inline: <AuthContext value={{ user, logout }}> const inline = { user, logout }; if (!Object.is(inline, prevInline)) consumerRenders.inline++; prevInline = inline; // Memoised: useMemo(() => ({ user, logout }), [user, logout]) if (!memoCache || memoCache.user !== user || memoCache.logout !== logout) { memoCache = { user, logout, value: { user, logout } }; } if (!Object.is(memoCache.value, prevMemo)) consumerRenders.memoised++; prevMemo = memoCache.value; } return consumerRenders;}Memoise the value (useMemo(() => ({ user, logout }), [user, logout])
with logout from useCallback), or split rarely-changing and
often-changing values into separate contexts so a reader only subscribes
to what it uses.
Common mistakes
- Reaching for context to avoid two levels of props. Passing props is
explicit and easy to trace; composition with
childrenoften removes the drilling without any global value. - An inline object or array as the provider value. It is a new object on every render, as the second example counts, so every consumer re-renders every time.
- Expecting the default value under a provider with
null. The default only applies when there is no provider;nullis a value. - Reading context in the same component that renders the provider.
useContextlooks above the component, so it does not see a provider the component itself returns.
What interviewers ask
- What problem does context solve, and what is the alternative? —
Passing data to many components at different depths without drilling;
composition through
childrenis the first thing to try. - Which value does
useContextreturn with nested providers? — The value of the closest provider above the component; the default only when there is none. - Why can context cause performance problems? — Every consumer re-renders when the value changes by identity; memoise the value or split the context.
Exercise · Pro
Resolve every context read
Exercises are free in the first 3 lessons of every course. From here on they are part of Pro and Sprint; the lesson and its runnable examples stay open.
Upgrade