I2 · Part 2 · Intermediate
Context API
A way to skip the middle layers — not a state manager, and not free.
08On this page
What it solves
Some values are genuinely ambient: the theme, the signed-in user, the locale, a toast dispatcher. Threading them through six components that do not care is noise. Context lets a provider put a value into the tree and any descendant read it directly.
const ThemeContext = createContext("light"); // default if no provider
function App() {
const [theme, setTheme] = useState("light");
return (
<ThemeContext value={theme}> // React 19: no .Provider needed
<Page />
</ThemeContext>
);
}
function DeepButton() {
const theme = useContext(ThemeContext); // no props threaded
return <button className={theme}>Save</button>;
}
Before React 19 the provider was <ThemeContext.Provider value={...}>.
Both work in 19; the old form is what you will see in existing code.
The default is not a fallback
createContext(defaultValue) is used only when a component reads
the context with no provider above it. That is almost always a mistake
rather than a feature, so make it loud:
const AuthContext = createContext(null);
export function useAuth() {
const ctx = useContext(AuthContext);
if (!ctx) throw new Error("useAuth must be used inside <AuthProvider>");
return ctx;
}
Exporting a hook rather than the context is worth doing anyway: it gives you
one place to add the check, keeps the context object private, and means
consumers never import useContext at all.
The performance trap
Every consumer re-renders when the value changes
Context does not do partial subscriptions. When the provider's
value changes by Object.is, every component
calling useContext for it re-renders — even if it only
reads a field that did not change.
<AuthContext value={{ user, login, logout }}> // ✗ new object every render
That object literal is a fresh reference on every render of the provider, so every consumer re-renders every time — including when the value is identical. Memoise it:
const value = useMemo(() => ({ user, login, logout }), [user, login, logout]);
<AuthContext value={value}>
And keep login/logout stable too, with
useCallback or by defining them outside — otherwise the memo
is recomputed anyway.
Split by change frequency
The most effective context optimisation is not memoisation, it is splitting. Values that change often and values that never change should not share a provider.
const StateContext = createContext(null); // changes on every keystroke
const DispatchContext = createContext(null); // never changes
<StateContext value={state}>
<DispatchContext value={dispatch}>{children}</DispatchContext>
</StateContext>
Now a component that only dispatches — a button, a form's submit —
reads DispatchContext and never re-renders when the state moves.
dispatch from useReducer is guaranteed stable, which
makes this pairing particularly clean.
What context is not
| Belief | Reality |
|---|---|
| "Context is a state manager" | It is a transport. It moves a value down the tree; you still need useState or useReducer to hold it. |
| "Context replaces Redux" | Only for values that rarely change. A real store gives selectors, middleware and devtools, and re-renders only what actually reads the changed slice. |
| "Context avoids prop drilling" | So does composition, with no re-render cost. Try passing the element as children first. |
| "One context for the app" | The fastest way to re-render everything on every change. Several small contexts beat one large one. |
A provider that is worth writing
function ThemeProvider({ children }) {
const [theme, setTheme] = useState(() => localStorage.getItem("theme") ?? "light");
useEffect(() => {
document.documentElement.dataset.theme = theme;
localStorage.setItem("theme", theme);
}, [theme]);
const value = useMemo(
() => ({ theme, toggle: () => setTheme((t) => (t === "light" ? "dark" : "light")) }),
[theme]
);
return <ThemeContext value={value}>{children}</ThemeContext>;
}
The provider owns the state, the side effect and the API. Everything below
gets useTheme() and knows nothing about storage or the DOM
attribute — which is the point.
Nesting and overriding
A consumer reads the nearest provider above it, so contexts can be overridden for a subtree. That is how a dark panel inside a light page works, and it is genuinely useful:
<ThemeContext value="light">
<Page>
<ThemeContext value="dark">
<Sidebar /> // reads "dark"
</ThemeContext>
</Page>
</ThemeContext>
When to use it
- Yes: theme, locale, the current user, a design-system config, a dispatch function, anything read by many components and written rarely.
- No: form values, a list being filtered, anything changing per keystroke — unless you have split the contexts carefully.
- Not yet: a prop travelling two layers. That is not drilling, that is just props.
Interview answer, one sentence
"Context is a transport for values that many components need and few
components change. Every consumer re-renders when the provider value changes
by identity, so the object goes in a useMemo and contexts get
split by how often they change — state in one, dispatch in another."
Opens in the editor: write it, run it, and check it against real tests.
All 66 for this level →Finished reading?
0 of 57 chapters read in this series