React Context API
The Context API is React’s built-in tool for sharing data across a component tree without manually passing props through every level in between. Instead of threading a theme or currentUser prop through five components that don’t actually use it themselves, you put the value in a context and let any descendant read it directly. This solves the classic prop drilling problem and is the foundation many state management libraries (and React’s own useReducer + Context pattern) build on top of.
Overview / How it works
Normally, data in React flows one way: from a parent component down to its children via props. That’s simple and predictable, but it breaks down when many components at different nesting depths need the same piece of data — a logged-in user, a UI theme, a selected language, a shopping cart. Passing that value down as a prop through every intermediate component (that itself has no use for it) is called prop drilling. It works, but it makes components harder to reuse and refactor, since a component in the middle of the tree ends up depending on a prop it never touches.
Context solves this by creating a kind of “broadcast channel” for a piece of data. You create a context object with createContext, wrap part of your tree in that context’s Provider component and give it a value, and then any descendant component — no matter how deeply nested — can call useContext to read that value directly, skipping all the components in between.
Under the hood, a context Provider doesn’t change how rendering starts; it changes what happens during rendering and re-rendering. When a component calls useContext(MyContext), React walks up the tree at render time to find the nearest matching MyContext.Provider ancestor and subscribes that component to it. If no Provider exists above it, useContext returns the default value passed to createContext. Whenever the Provider’s value prop changes between renders (compared with Object.is), React re-renders every component that consumes that context, even if those components are far away and even if they’re wrapped in React.memo — context reads bypass memoization, because the consuming component itself re-renders when its subscribed context value changes. This is a key performance detail: put too much unrelated state into one big context value, and every small change re-renders every consumer, however deep.
Context is a mechanism for reading and writing shared values — it is not itself a state manager. You still need useState or useReducer to hold the actual value; Context just makes that value available anywhere below the Provider without manual prop passing. That’s why in real apps, Context and hooks are almost always combined: a custom Provider component owns the state with useState/useReducer, and a custom hook wraps useContext so consumers get a clean API.
Syntax
import { createContext, useContext } from "react";
// 1. Create the context (optionally with a default value)
const MyContext = createContext(defaultValue);
// 2. Provide a value somewhere up the tree
function Parent() {
return (
<MyContext.Provider value={someValue}>
<Child />
</MyContext.Provider>
);
}
// 3. Consume it anywhere below, at any depth
function Child() {
const value = useContext(MyContext);
return <p>{value}</p>;
}
| Part | Purpose |
|---|---|
createContext(defaultValue) |
Creates a context object. defaultValue is only used when a component reads the context with no matching Provider above it in the tree. |
<MyContext.Provider value={...}> |
Makes value available to every descendant that calls useContext(MyContext), no matter how deeply nested. |
useContext(MyContext) |
Reads the current value from the nearest enclosing Provider. Re-renders the calling component whenever that value changes. |
Examples
Example 1: A basic theme context
import { createContext, useContext } from "react";
const ThemeContext = createContext("light");
function ThemedButton() {
const theme = useContext(ThemeContext);
return (
<button className={`btn btn-${theme}`}>
I am styled by theme context
</button>
);
}
function Toolbar() {
// Toolbar does not know or care about the theme
return (
<div>
<ThemedButton />
</div>
);
}
export default function App() {
return (
<ThemeContext.Provider value="dark">
<Toolbar />
</ThemeContext.Provider>
);
}
Renders: a button with class btn btn-dark and the text “I am styled by theme context”. Notice that Toolbar never receives a theme prop at all — ThemedButton reads it straight from context, even though Toolbar sits in between. If you removed the Provider, useContext(ThemeContext) would fall back to the default value, "light".
Example 2: Context with state — a theme toggle
import { createContext, useContext, useState } from "react";
const ThemeContext = createContext(null);
export function ThemeProvider({ children }) {
const [theme, setTheme] = useState("light");
const toggleTheme = () => {
setTheme((prev) => (prev === "light" ? "dark" : "light"));
};
return (
<ThemeContext.Provider value={{ theme, toggleTheme }}>
{children}
</ThemeContext.Provider>
);
}
export function useTheme() {
const context = useContext(ThemeContext);
if (context === null) {
throw new Error("useTheme must be used within a ThemeProvider");
}
return context;
}
function ThemeToggleButton() {
const { theme, toggleTheme } = useTheme();
return (
<button onClick={toggleTheme}>
Current theme: {theme}
</button>
);
}
export default function App() {
return (
<ThemeProvider>
<ThemeToggleButton />
</ThemeProvider>
);
}
Renders: a button reading “Current theme: light”. Clicking it flips the state to “dark”, the Provider’s value object changes, and every component consuming ThemeContext — here just ThemeToggleButton — re-renders to show “Current theme: dark”. This example introduces two patterns you’ll see in almost every real app: a dedicated Provider component that owns the state, and a custom hook (useTheme) that wraps useContext and throws a clear error if it’s used outside the Provider, instead of silently returning null.
Example 3: A realistic auth context with a stabilized value
import { createContext, useContext, useState, useMemo, useCallback } from "react";
const AuthContext = createContext(null);
export function AuthProvider({ children }) {
const [user, setUser] = useState(null);
const login = useCallback((name) => {
setUser({ name });
}, []);
const logout = useCallback(() => {
setUser(null);
}, []);
const value = useMemo(
() => ({ user, login, logout }),
[user, login, logout]
);
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
}
export function useAuth() {
const context = useContext(AuthContext);
if (context === null) {
throw new Error("useAuth must be used within an AuthProvider");
}
return context;
}
function LoginButton() {
const { user, login, logout } = useAuth();
if (user) {
return (
<div>
<p>Welcome, {user.name}!</p>
<button onClick={logout}>Log out</button>
</div>
);
}
return <button onClick={() => login("Priya")}>Log in</button>;
}
export default function App() {
return (
<AuthProvider>
<LoginButton />
</AuthProvider>
);
}
Renders: initially a button reading “Log in”. Clicking it calls login("Priya"), which sets user to { name: "Priya" }; the component then shows “Welcome, Priya!” with a “Log out” button. The useCallback-wrapped functions and the useMemo around the context value mean the Provider only produces a new value object when user actually changes — not on every render of AuthProvider — which matters once other state (unrelated to auth) lives higher up in the tree.
How it works step by step / Under the hood
On mount: React renders the Provider, evaluates its value prop, and renders its children. Any descendant that calls useContext for that context is registered as a subscriber and receives the current value on this first render.
On a value update: when the state backing the Provider’s value changes (for example, a setState call inside the Provider), React re-renders the Provider, computes the new value, and compares it to the previous one with Object.is. If it differs, React re-renders every subscribed consumer with the new value — regardless of how deep they are in the tree, and even if the components between the Provider and the consumer chose not to re-render (their own render output is reused, but the consumer itself is still updated). This is why creating a brand-new object or array literal for value on every render (value={{ theme, setTheme }}) causes every consumer to re-render on every Provider render, since object identity changes even when the contents are the same — hence the useMemo pattern in Example 3.
On unmount: when the Provider (or a consuming component) is removed from the tree, React tears down its subscriptions and any effects along with it, same as with any unmounted component. There is no separate context-specific cleanup step to worry about — it follows normal component lifecycle rules.
Common Mistakes
Mistake 1 — passing a fresh object literal as the value on every render.
function ThemeProvider({ children }) {
const [theme, setTheme] = useState("light");
// ❌ A new object is created on every render of ThemeProvider,
// so every consumer re-renders even if theme didn't change.
return (
<ThemeContext.Provider value={{ theme, setTheme }}>
{children}
</ThemeContext.Provider>
);
}
Because {{ theme, setTheme }} is a brand-new object reference every time ThemeProvider renders (even if theme itself hasn’t changed), every consumer of ThemeContext re-renders whenever anything else causes ThemeProvider to re-render. Wrap the value in useMemo, keyed on the actual dependencies, as shown in Example 3.
Mistake 2 — calling useContext conditionally.
function Profile({ showDetails }) {
if (showDetails) {
const user = useContext(UserContext); // ❌ hook called conditionally
return <p>{user.name}</p>;
}
return null;
}
Hooks — including useContext — must run in the exact same order on every render, because React matches hook calls to their stored state by call order, not by name. Putting a hook inside an if block means it might run on one render and not the next, corrupting that matching. Call useContext unconditionally at the top of the component, then branch on the value it returns:
function Profile({ showDetails }) {
const user = useContext(UserContext); // ✅ always called
if (!showDetails) return null;
return <p>{user.name}</p>;
}
Mistake 3 — forgetting to wrap the tree in a Provider and being surprised by the default value. If a component calls useContext(MyContext) without any MyContext.Provider above it, it silently receives whatever default value you passed to createContext — often null or undefined — instead of an error. Destructuring properties off that default value then throws a confusing runtime error far from the real cause. This is exactly why Examples 2 and 3 use a custom hook that checks for null and throws a clear, descriptive error instead of letting the mistake surface later as “Cannot read properties of null”.
Best Practices
- Split unrelated state into separate contexts (e.g.
AuthContextandThemeContext) instead of one giant context — that way a change to the theme doesn’t re-render every component that only cares about auth. - Wrap the context value in
useMemo(and stabilize functions withuseCallback) whenever the value is an object or array, so consumers don’t re-render on every Provider render. - Export a custom hook (like
useAuthoruseTheme) alongside the Provider instead of exporting the raw context — it gives consumers a clean API and lets you throw a helpful error if it’s used outside the Provider. - Reach for Context to avoid prop drilling of relatively static or infrequently-changing data (theme, locale, current user). For state that changes very rapidly (like mouse position or every keystroke in a large form), Context re-renders can become a bottleneck — consider a state library or narrower state instead.
- Keep the Provider as close as possible to the components that actually need it, rather than always wrapping the entire app, so unrelated parts of the tree aren’t forced to re-render.
Practice Exercises
Exercise 1: Create a LanguageContext with a default value of "en", a LanguageProvider that holds the current language in state, and a useLanguage custom hook. Render a component that displays the current language and a button that switches between "en" and "es".
Exercise 2: Take the object-literal mistake shown in Common Mistakes (a Provider passing value={{ theme, setTheme }} directly) and fix it using useMemo. Explain in a comment why the fix prevents unnecessary re-renders.
Exercise 3: Build a small shopping cart: a CartContext exposing items, an addItem function, and a removeItem function. Render a ProductList component (adds items) and a separate CartSummary component (shows the count and a remove button per item) that are siblings several levels apart in the tree, proving neither needs the cart passed as a prop.
Summary
- Context lets any descendant component read a value directly from an ancestor
Provider, skipping prop drilling through components that don’t need the data. createContext(defaultValue)creates the context; the default value is only used when there’s no matching Provider above the reading component.useContext(MyContext)subscribes a component to the nearest Provider’s value and re-renders it whenever that value changes.- A changed
value(byObject.iscomparison) re-renders every consumer, so stabilize object/array values withuseMemoand functions withuseCallbackto avoid unnecessary re-renders. - Pair a context with a custom Provider component (holding state via
useState/useReducer) and a custom hook (wrappinguseContext) for a clean, safe API. - Context is a data-sharing mechanism, not a full state manager — it doesn’t replace
useState/useReducer, it distributes the values they produce.
