What is the Context API?
The Context API is React’s built-in way to pass data through a component tree without threading props through every level. It solves a specific, common problem: a value that many components need, but which sits far above them in the tree.
The classic example is theming. A Button deep in the tree needs the current theme, but every component between the top and the button would have to accept and forward a theme prop. That is prop drilling, and Context removes it. You provide the value once, and any descendant can read it directly.
Creating and providing context
Context has three pieces: an object, a provider and a consumer hook.
// AuthContext.jsx
import { createContext, useContext, useMemo, useState } from "react";
const AuthContext = createContext(null);
export function AuthProvider({ children }) {
const [user, setUser] = useState(null);
const value = useMemo(
() => ({
user,
login: (nextUser) => setUser(nextUser),
logout: () => setUser(null),
}),
[user],
);
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
}
export function useAuth() {
const context = useContext(AuthContext);
if (!context) {
throw new Error("useAuth must be used within an AuthProvider");
}
return context;
}
createContext defines the context and a default value. The provider supplies the current value to everything below it. The custom hook reads the value and gives a clear error if the component is used outside the provider.
Consuming context
Any descendant can read the value, no matter how deep it sits.
// Header.jsx
import { useAuth } from "./AuthContext";
export function Header() {
const { user, logout } = useAuth();
return (
<header>
<span>{user ? `Hi, ${user.name}` : "Guest"}</span>
{user && <button onClick={logout}>Sign out</button>}
</header>
);
}
The Header never received a prop from App. It reached into the tree for the value it needed, which keeps intermediate components unaware of data they do not use.
Context is not state
This distinction trips people up. Context does not hold state or manage updates. It transports a value. The state itself lives in useState or useReducer, and the provider passes it down.
That is why a common pattern pairs context with a reducer:
// CartContext.jsx
import { createContext, useContext, useReducer } from "react";
const CartContext = createContext(null);
function reducer(state, action) {
switch (action.type) {
case "add":
return [...state, action.item];
case "remove":
return state.filter((item) => item.id !== action.id);
default:
return state;
}
}
export function CartProvider({ children }) {
const [items, dispatch] = useReducer(reducer, []);
return (
<CartContext.Provider value={{ items, dispatch }}>
{children}
</CartContext.Provider>
);
}
export const useCart = () => useContext(CartContext);
The reducer keeps updates predictable, and context makes them available anywhere without prop drilling.
Performance: the real constraint
When a provider’s value changes, every component that reads that context re-renders. There is no selector, so you cannot subscribe to only part of the value. Two habits keep this manageable.
Memoise the value. An object literal creates a new reference every render, which means every consumer re-renders even when nothing changed.
// memoised
const value = useMemo(() => ({ user, logout }), [user]);
Split contexts. Put state and actions in separate contexts, or split by feature, so a component that only needs to dispatch an action does not re-render when the state changes.
// split.jsx
const CartStateContext = createContext(null);
const CartActionsContext = createContext(null);
Components that only call actions subscribe to the actions context, which never changes identity, so they never re-render from a state update. This one trick solves most context performance complaints.
When to use Context, and when not to
Context is the right tool for:
- Theme and colour mode.
- The authenticated user and session.
- Locale and translations.
- A router or a small, stable configuration.
Reach for a dedicated store when you have:
- Frequent updates, such as typing, dragging or animations.
- A large state tree where components need selectors.
- A need for middleware, persistence or time-travel devtools.
- Server state that needs caching and refetching.
Libraries like Zustand and Redux Toolkit add selectors so components subscribe to slices rather than the whole value. For server data, TanStack Query handles caching entirely.
Best practices
- Wrap
useContextin a custom hook and check for a missing provider. - Memoise provider values with
useMemo. - Split contexts by concern, especially state versus actions.
- Keep context values small and focused.
- Use
useReducerwhen updates are complex or depend on previous state. - Do not use context for high-frequency updates; use a selector-based store.
- Provide sensible defaults so components can render outside a provider when appropriate.
Common mistakes
- Treating context as a state manager and expecting selectors.
- Passing an inline object as the provider value and re-rendering everything.
- Putting unrelated data in one giant context.
- Forgetting the default value and crashing on
undefined. - Using context for values that only one or two components need.
- Expecting context to optimise re-renders automatically.
Where to go next
Context is the foundation of React state sharing, and knowing its limits tells you when to graduate. Compare it with Redux Toolkit for predictable large-scale state and Zustand for a lightweight selector store, then handle server data with TanStack Query. For Vue, the equivalent concept is covered in the Pinia guide.