Context API (useContext)
The full Context story: what problem prop-drilling causes, how createContext/Provider/useContext work, the idiomatic provider pattern, the re-render-all-consumers pitfall and every mitigation (context splitting, value memoisation, state/dispatch separation), and exactly when Context is the wrong tool.
Why Context exists — the prop-drilling problem. In React, data flows down through props. When a deeply nested component needs a value (the logged-in user, the theme, the locale), the natural approach is to pass it as a prop — but that value has to be threaded through every intermediate component in between, even the ones that never use it. This is prop-drilling: layers of <Layout user={user}> → <Sidebar user={user}> → <Menu user={user}> where only the leaf actually reads user. It is noisy, brittle (rename the prop and touch ten files), and couples middle components to data they do not care about. Context solves exactly this: it lets a value be published once at the top and read directly by any descendant, skipping the middle entirely.
What Context actually is. React.createContext(defaultValue) returns an object with two things: a Provider component and (historically) a Consumer. The Provider takes a value prop and makes it available to every component beneath it in the tree, no matter how deep. A descendant subscribes to that value by calling useContext(TheContext). Conceptually Context is a typed, tree-scoped dependency-injection channel: the Provider registers a value for a subtree, and consumers pull it by identity rather than by having it handed down prop by prop.
The default value and why it is often a sentinel. The argument to createContext is used only when a component calls useContext without a matching Provider above it. Many teams pass undefined (or null) as the default deliberately, so that consuming outside the intended Provider produces a detectable state instead of a silently-wrong value. That sentinel becomes the basis for the throwing custom hook described below. If you pass a real default (e.g. a light theme), consumers outside a Provider quietly get that default — sometimes fine, sometimes a hidden bug.
The idiomatic five-step provider pattern. Production code almost always follows the same shape. (1) Define the value type — an interface describing exactly what the context exposes (state plus the functions that change it). (2) Create the context with createContext<Type | undefined>(undefined) so misuse is detectable. (3) Write a Provider component that owns the real state with useState/useReducer, defines the mutation functions, and renders <Ctx.Provider value={...}>{children}</Ctx.Provider>. (4) Expose a custom hook (e.g. useAuth) that calls useContext, throws a clear error if the value is undefined, and returns the typed value. (5) Consume the hook anywhere in the subtree. This packages the whole feature behind one import and one hook.
The central pitfall: every consumer re-renders on every value change. This is the single most important thing to understand about Context. When the Provider's value changes by reference, React re-renders every component that calls useContext on that context — even components that only read a field which did not change. Context has no selector mechanism; it is all-or-nothing per context object. For low-frequency data (auth, theme, locale) that is completely fine. For high-frequency data (the text in an input on every keystroke, a fast-ticking timer, a large frequently-mutated list) it causes wide, wasteful re-render storms.
Pitfall amplifier: a fresh object literal as value. A subtle trap: value={{ user, login, logout }} creates a new object every render of the Provider. Even if user did not change, the object reference did, so all consumers re-render whenever the Provider re-renders for any reason. The fix is to memoise the value with useMemo(() => ({ user, login, logout }), [user]) and wrap the functions in useCallback, so the reference is stable when the underlying data is stable. Forgetting this is the most common Context performance bug in real code.
Mitigation one: split into multiple focused contexts. If a context bundles many unrelated concerns, any change to one re-renders consumers of all. Splitting into separate contexts (a ThemeContext, an AuthContext, a LocaleContext) means a theme toggle only re-renders theme consumers. Narrow contexts have a smaller blast radius. This is the primary architectural lever for Context performance.
Mitigation two: separate state from dispatch. A powerful pattern with useReducer is to expose the state through one context and the dispatch function through another. Components that only dispatch actions (a button that fires addTodo) subscribe to the dispatch context, whose value (the dispatch function) is stable forever, so they never re-render when the state changes. Only components that actually read state subscribe to the state context. This cleanly divides readers from writers.
Context is not a state manager. Context is a transport mechanism — it moves a value down the tree. It does not store state, batch updates, provide selectors, offer devtools, or optimise re-renders. The state itself always lives in some useState/useReducer inside a Provider. So 'use Context vs use Redux' is slightly the wrong framing: Context is how you distribute state, while Redux/Zustand/Jotai are how you manage and optimise it. They frequently combine — react-redux uses Context under the hood to pass the store down, then layers selector-based subscriptions on top.
Server Components and Context (modern Next.js). In the React Server Components world, Context only works inside Client Components — a Provider must live in a file marked 'use client', and only Client Components can call useContext. Server Components cannot read Context; they pass data down as props or through server-side mechanisms instead. This is a common gotcha when adding a ThemeProvider to a Next.js App Router layout: the provider file needs 'use client'.
Testing and composition. Because a Provider is just a component, tests wrap the component under test in the real (or a mock) Provider to inject known values — no globals to reset between tests. Multiple providers compose by nesting, which produces the familiar 'provider pyramid' at the app root; a small <AppProviders> component that nests them keeps the root readable.
When Context is the right tool — and when it is not. Reach for Context when many components across the tree need the same, relatively stable value: current user, theme, locale, feature flags, a design-system config. Do not reach for Context for high-frequency updates (lift local state, or use a store with selectors), for server data (use TanStack Query/SWR), or as a lazy global-variable dumping ground. If you find yourself memoising heroically to stop re-render storms, you have probably outgrown Context and want a store.
The mental model (memorise this). Context is tree-scoped dependency injection: a Provider publishes one value to a subtree, and any descendant reads it directly via a hook — killing prop-drilling. The price is that every consumer re-renders whenever the value's reference changes, so you keep the value stable (memoise it), split contexts by concern, and separate state from dispatch. Use it for low-frequency global values; it distributes state, it does not manage it.
Context is Spring's dependency-injection container scoped to a subtree instead of the whole app. `createContext` declares a bean type, the Provider is the `@Configuration` that binds a concrete instance for everything below it, and `useContext` is `@Autowired` — a component asks for the dependency by type and the framework supplies the nearest registered instance, no constructor threading (prop-drilling) required. The re-render-all-consumers pitfall maps to a coarse-grained bean whose change invalidates every collaborator that injected it: the fix (splitting contexts, separating state from dispatch) is the same as splitting a fat god-bean into focused, single-responsibility beans so a change ripples only to the collaborators that truly depend on it. Memoising the value object is like making the injected bean effectively immutable so downstream caches stay valid.
- Context kills prop-drilling: a Provider publishes one value to a whole subtree and any descendant reads it directly, so intermediate components stop forwarding props they do not use.
- The core cost: when the Provider value changes BY REFERENCE, every component that calls useContext on it re-renders, even ones reading an unchanged field. There is no built-in selector.
- value={{ ... }} creates a new object every render, forcing all consumers to re-render; memoise it with useMemo and wrap callbacks in useCallback to keep the reference stable.
- Split one fat context into several focused ones (theme, auth, locale) to shrink the re-render blast radius.
- Separate state and dispatch into two contexts; dispatch is stable, so write-only components never re-render on state changes.
- Create the context with a sentinel default (undefined) and wrap consumption in a custom hook that throws when used outside its Provider.
- Context transports state; it does not manage it. The real state lives in useState/useReducer inside the Provider.
- Use Context for low-frequency global values (auth, theme, locale, config); avoid it for high-frequency updates and for server data.
- In Next.js App Router, Providers must be Client Components ('use client'); Server Components cannot call useContext.
- Providers compose by nesting; a single AppProviders wrapper keeps the root tidy and makes tests trivial by injecting mock values.
Worked Code
import {
createContext, useContext, useState, useCallback, useMemo, ReactNode,
} from 'react';
interface User { id: string; name: string; }
// 1. Define exactly what the context exposes
interface AuthContextType {
user: User | null;
login: (token: string) => void;
logout: () => void;
}
// 2. Create with a sentinel default so misuse is detectable
const AuthContext = createContext<AuthContextType | undefined>(undefined);
// 3. Provider owns the state and the mutation functions
function AuthProvider({ children }: { children: ReactNode }) {
const [user, setUser] = useState<User | null>(null);
// useCallback keeps these references stable across renders
const login = useCallback((token: string) => {
localStorage.setItem('token', token);
setUser(decodeJWT(token));
}, []);
const logout = useCallback(() => {
localStorage.removeItem('token');
setUser(null);
}, []);
// useMemo keeps the VALUE object stable unless user changes — this is
// the fix for the "new object every render forces all consumers to
// re-render" pitfall.
const value = useMemo(
() => ({ user, login, logout }),
[user, login, logout],
);
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
}
// 4. Custom hook: typed, throws outside the Provider
function useAuth(): AuthContextType {
const ctx = useContext(AuthContext);
if (!ctx) throw new Error('useAuth must be used inside <AuthProvider>');
return ctx;
}
// 5. Consume anywhere in the subtree — no prop-drilling
function Navbar() {
const { user, logout } = useAuth();
return <button onClick={logout}>Logout {user?.name}</button>;
}
declare function decodeJWT(t: string): User;// BAD: a fresh object literal every render.
// Every consumer re-renders whenever <ThemeProvider> re-renders for ANY
// reason, even if 'theme' did not actually change.
function ThemeProviderBad({ children }: { children: React.ReactNode }) {
const [theme, setTheme] = React.useState('light');
return (
<ThemeCtx.Provider value={{ theme, setTheme }}>
{children}
</ThemeCtx.Provider>
);
}
// GOOD: memoise the value so its reference is stable when theme is stable.
function ThemeProviderGood({ children }: { children: React.ReactNode }) {
const [theme, setTheme] = React.useState('light');
const value = React.useMemo(() => ({ theme, setTheme }), [theme]);
return <ThemeCtx.Provider value={value}>{children}</ThemeCtx.Provider>;
}
const ThemeCtx = React.createContext<{ theme: string; setTheme: (t: string) => void } | undefined>(undefined);import { createContext, useContext, useReducer, useMemo, ReactNode, Dispatch } from 'react';
type Todo = { id: number; text: string; done: boolean };
type Action =
| { type: 'add'; text: string }
| { type: 'toggle'; id: number };
function reducer(state: Todo[], action: Action): Todo[] {
switch (action.type) {
case 'add': return [...state, { id: Date.now(), text: action.text, done: false }];
case 'toggle': return state.map(t => t.id === action.id ? { ...t, done: !t.done } : t);
}
}
// Two separate contexts: one for reading, one for writing.
const TodosStateCtx = createContext<Todo[] | undefined>(undefined);
const TodosDispatchCtx = createContext<Dispatch<Action> | undefined>(undefined);
function TodosProvider({ children }: { children: ReactNode }) {
const [state, dispatch] = useReducer(reducer, []);
// 'state' changes often; 'dispatch' is stable forever.
return (
<TodosStateCtx.Provider value={state}>
<TodosDispatchCtx.Provider value={dispatch}>
{children}
</TodosDispatchCtx.Provider>
</TodosStateCtx.Provider>
);
}
// A button that only dispatches subscribes ONLY to dispatch, so it never
// re-renders when the todo list changes.
function useTodosDispatch() {
const d = useContext(TodosDispatchCtx);
if (!d) throw new Error('useTodosDispatch outside provider');
return d;
}'use client'; // REQUIRED: Providers/useContext only work in Client Components
import { ReactNode } from 'react';
// Nesting many providers gets noisy at the app root, so wrap them once.
export function AppProviders({ children }: { children: ReactNode }) {
return (
<AuthProvider>
<ThemeProvider>
<LocaleProvider>
{children}
</LocaleProvider>
</ThemeProvider>
</AuthProvider>
);
}
// app/layout.tsx (a Server Component) can render <AppProviders> as a child;
// the 'use client' boundary lives inside AppProviders, keeping the layout
// itself a Server Component.
declare function AuthProvider(p: { children: ReactNode }): JSX.Element;
declare function ThemeProvider(p: { children: ReactNode }): JSX.Element;
declare function LocaleProvider(p: { children: ReactNode }): JSX.Element;▶Try It Live
Edit the code and press Run — it executes safely in a sandboxed iframe. Use the Console tab for log output.
Interview-Ready Q&A
Prop-drilling means threading a prop through every intermediate component just to reach a deep descendant, even though the middle components never use it — that is noisy and brittle. Context lets a Provider publish a value to an entire subtree, and any descendant reads it directly with useContext, so the intermediate components stay clean. It is a tree-scoped dependency-injection channel, best for stable global values like auth, theme, and locale.
- 1Context = tree-scoped dependency injection: Provider publishes a value, descendants read it via useContext, no prop-drilling.
- 2Every consumer re-renders when the value's reference changes — there is no built-in selector.
- 3Never pass a raw object literal as value; memoise it with useMemo and stabilise functions with useCallback.
- 4Split fat contexts into focused ones (theme/auth/locale) to reduce the re-render blast radius.
- 5Separate state and dispatch contexts so write-only components never re-render on state changes.
- 6Create with an undefined default and consume through a custom hook that throws outside the Provider.
- 7Context distributes state; useState/useReducer inside the Provider manages it.
- 8Use it for low-frequency global data; keep high-frequency and server data out.
- 9In Next.js App Router, Providers need 'use client'; Server Components cannot use Context.
- 10Compose providers by nesting; a single AppProviders wrapper keeps the root clean and tests easy.