
Avoiding Prop Drilling with the React Context API
Your App component knows who the logged-in user is. The avatar that needs that user lives inside Header, inside Navbar, inside UserMenu. So you pass user to Header, which passes it to Navbar, which passes it to UserMenu, which finally passes it to Avatar. Three of those components never use user at all. They just carry it. Add a theme and a locale, and every component signature in the chain fills up with props it doesn't care about.
That's prop drilling. It isn't a bug, and for one or two levels it's perfectly fine. But deep chains make components harder to reuse, make refactors painful, and turn a simple "rename this prop" into a ten-file change.
React's Context API lets a parent make a value available to its entire subtree, so any component can read it directly without intermediate components passing it along. This post covers how Context works in React 19, how to build a clean typed provider and hook, how to manage complex state through Context with a reducer, how to keep re-renders under control, and when composition is a better fix than Context.
What Prop Drilling Looks Like
Here's the drilling chain in code:
type User = { name: string; avatarUrl: string };
function App() {
const user: User = { name: "Ada", avatarUrl: "/ada.png" };
return <Header user={user} />;
}
function Header({ user }: { user: User }) {
return (
<header>
<Logo />
<Navbar user={user} />
</header>
);
}
function Navbar({ user }: { user: User }) {
return (
<nav>
<a href="/">Home</a>
<UserMenu user={user} />
</nav>
);
}
function UserMenu({ user }: { user: User }) {
return <Avatar user={user} />;
}
function Avatar({ user }: { user: User }) {
return <img src={user.avatarUrl} alt={user.name} />;
}
function Logo() {
return <strong>Acme</strong>;
}
Header, Navbar, and UserMenu all depend on the User type even though they never read it. If Avatar later needs the user's role too, nothing else needs to change, but if you split User into a different shape, every layer is affected.
Context in Three Parts
Context has three pieces:
- Create the context with
createContext(defaultValue). - Provide a value by rendering the context around a subtree.
- Consume the value in any descendant with
useContext(oruse).
import { createContext, useContext } from "react";
type User = { name: string; avatarUrl: string };
const UserContext = createContext<User | null>(null);
function App() {
const user: User = { name: "Ada", avatarUrl: "/ada.png" };
return (
<UserContext value={user}>
<Header />
</UserContext>
);
}
function Header() {
return (
<header>
<Navbar />
</header>
);
}
function Navbar() {
return (
<nav>
<a href="/">Home</a>
<Avatar />
</nav>
);
}
function Avatar() {
const user = useContext(UserContext);
if (!user) return <a href="/login">Sign in</a>;
return <img src={user.avatarUrl} alt={user.name} />;
}
The middle components are now free of user entirely. Avatar reads it straight from the nearest UserContext above it.
In React 19 you render the context object directly as the provider: <UserContext value={user}>. In React 18 and earlier, you write <UserContext.Provider value={user}>. The .Provider form still works in React 19 but is expected to be deprecated.
How Lookup Works
useContext walks up the tree and uses the value from the closest provider. If there's no provider at all, it returns the default value passed to createContext. Providers can be nested, and an inner provider overrides an outer one for its subtree. That's useful for things like a dark section inside a light page:
<ThemeContext value="light">
<Page>
<ThemeContext value="dark">
<Sidebar />
</ThemeContext>
</Page>
</ThemeContext>
The Provider and Hook Pattern
In real apps you rarely export the raw context. Instead, you wrap it in a provider component that owns the state and a custom hook that reads it. This gives you one place to manage the state, a typed API, and a clear error if someone forgets the provider.
// auth-context.tsx
import { createContext, useContext, useState, type ReactNode } from "react";
type User = { id: string; name: string; avatarUrl: string };
type AuthContextValue = {
user: User | null;
login: (user: User) => void;
logout: () => void;
};
const AuthContext = createContext<AuthContextValue | null>(null);
export function AuthProvider({ children }: { children: ReactNode }) {
const [user, setUser] = useState<User | null>(null);
const value: AuthContextValue = {
user,
login: setUser,
logout: () => setUser(null),
};
return <AuthContext value={value}>{children}</AuthContext>;
}
export function useAuth() {
const ctx = useContext(AuthContext);
if (!ctx) {
throw new Error("useAuth must be used inside an AuthProvider");
}
return ctx;
}
Wrap your app once:
// main.tsx
import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import { AuthProvider } from "./auth-context";
import { App } from "./App";
createRoot(document.getElementById("root")!).render(
<StrictMode>
<AuthProvider>
<App />
</AuthProvider>
</StrictMode>,
);
And consume anywhere:
import { useAuth } from "./auth-context";
export function UserMenu() {
const { user, login, logout } = useAuth();
if (!user) {
return (
<button
onClick={() => login({ id: "1", name: "Ada", avatarUrl: "/ada.png" })}
>
Sign in
</button>
);
}
return (
<div>
<img src={user.avatarUrl} alt="" width={32} height={32} />
<span>{user.name}</span>
<button onClick={logout}>Sign out</button>
</div>
);
}
Using null as the default and throwing in the hook is deliberate. A component that reads auth state without a provider is a bug, and failing loudly is better than silently rendering with a fake default user. The hook also narrows the type, so consumers never deal with null for the context itself. If custom hooks are new to you, see building your own custom hooks in React.
Managing Complex State: Context Plus useReducer
For state with several operations, like a shopping cart, combine Context with useReducer. The reducer holds the update logic, and Context distributes the state and dispatch:
// cart-context.tsx
import {
createContext,
useContext,
useReducer,
type Dispatch,
type ReactNode,
} from "react";
type CartItem = { id: string; name: string; price: number; qty: number };
type CartState = { items: CartItem[] };
type CartAction =
| { type: "add"; item: Omit<CartItem, "qty"> }
| { type: "remove"; id: string }
| { type: "clear" };
function cartReducer(state: CartState, action: CartAction): CartState {
switch (action.type) {
case "add": {
const existing = state.items.find((i) => i.id === action.item.id);
if (existing) {
return {
items: state.items.map((i) =>
i.id === action.item.id ? { ...i, qty: i.qty + 1 } : i,
),
};
}
return { items: [...state.items, { ...action.item, qty: 1 }] };
}
case "remove":
return { items: state.items.filter((i) => i.id !== action.id) };
case "clear":
return { items: [] };
}
}
const CartStateContext = createContext<CartState | null>(null);
const CartDispatchContext = createContext<Dispatch<CartAction> | null>(null);
export function CartProvider({ children }: { children: ReactNode }) {
const [state, dispatch] = useReducer(cartReducer, { items: [] });
return (
<CartStateContext value={state}>
<CartDispatchContext value={dispatch}>{children}</CartDispatchContext>
</CartStateContext>
);
}
export function useCart() {
const ctx = useContext(CartStateContext);
if (!ctx) throw new Error("useCart must be used inside a CartProvider");
return ctx;
}
export function useCartDispatch() {
const ctx = useContext(CartDispatchContext);
if (!ctx)
throw new Error("useCartDispatch must be used inside a CartProvider");
return ctx;
}
Notice there are two contexts: one for state, one for dispatch. That's the most useful performance trick for Context, explained next.
import { useCart, useCartDispatch } from "./cart-context";
export function AddToCartButton({
id,
name,
price,
}: {
id: string;
name: string;
price: number;
}) {
const dispatch = useCartDispatch();
return (
<button
onClick={() => dispatch({ type: "add", item: { id, name, price } })}
>
Add to cart
</button>
);
}
export function CartBadge() {
const { items } = useCart();
const count = items.reduce((sum, i) => sum + i.qty, 0);
return <span aria-label={`${count} items in cart`}>🛒 {count}</span>;
}
Context and Re-Renders
Every component that reads a context re-renders when the context value changes, compared with Object.is. That has two consequences you need to design around.
1. A New Object Every Render Re-Renders Every Consumer
In the AuthProvider above, value is a new object on every render of the provider. If the provider re-renders for an unrelated reason, every useAuth consumer re-renders too. Memoize the value when the provider can re-render often:
// auth-context.tsx: same file as before, only the provider changes
import {
createContext,
useCallback,
useContext,
useMemo,
useState,
type ReactNode,
} from "react";
export function AuthProvider({ children }: { children: ReactNode }) {
const [user, setUser] = useState<User | null>(null);
const logout = useCallback(() => setUser(null), []);
const value = useMemo(
() => ({ user, login: setUser, logout }),
[user, logout],
);
return <AuthContext value={value}>{children}</AuthContext>;
}
If you use the React Compiler, it memoizes this object for you.
2. Split Contexts That Change at Different Rates
In the cart example, AddToCartButton only needs dispatch, which never changes. Because it reads CartDispatchContext rather than CartStateContext, it doesn't re-render when items are added. If both lived in one context as { state, dispatch }, every button on the page would re-render on every cart change.
The general rule: group values by how often they change, and put rarely changing values (functions, config, the current user) in different contexts from frequently changing ones (form input, timers, cart contents).
Context is not built for high-frequency updates like mouse position or text typed in an input shared across the whole app. Every consumer re-renders on each change, and there's no built-in way to subscribe to just part of the value. For that kind of state, a store library with selectors fits better. Context API vs Redux compares the trade-offs.
Composition: Sometimes You Don't Need Context
Before adding Context, check whether the drilling can disappear by restructuring. Often a middle component only passes a prop through because it renders the component that needs it. If it accepts children instead, the parent can render that component directly:
import type { ReactNode } from "react";
type User = { name: string; avatarUrl: string };
function App() {
const user: User = { name: "Ada", avatarUrl: "/ada.png" };
return (
<Header>
<Navbar>
<Avatar user={user} />
</Navbar>
</Header>
);
}
function Header({ children }: { children: ReactNode }) {
return <header>{children}</header>;
}
function Navbar({ children }: { children: ReactNode }) {
return (
<nav>
<a href="/">Home</a>
{children}
</nav>
);
}
function Avatar({ user }: { user: User }) {
return <img src={user.avatarUrl} alt={user.name} />;
}
user now travels exactly one level, from App to Avatar. No Context, no hidden dependencies, and Header and Navbar became more reusable because they accept any content. Layout components with slots (children, sidebar, actions props) solve a surprising amount of prop drilling. When you only need to share state between two nearby siblings, plain lifting state up is simpler still.
Reading Context With use
React 19 adds use, which can read a context like useContext but, unlike hooks, can be called inside conditions and loops:
import { createContext, use } from "react";
const CurrencyContext = createContext("USD");
function Price({
amount,
showCurrency,
}: {
amount: number;
showCurrency: boolean;
}) {
if (!showCurrency) return <span>{amount.toFixed(2)}</span>;
const currency = use(CurrencyContext);
return (
<span>
{new Intl.NumberFormat("en", { style: "currency", currency }).format(
amount,
)}
</span>
);
}
useContext remains perfectly fine for everyday use. use is handy when you only need the context on some branches. See exploring the React use hook for more.
Common Mistakes With React Context
- Putting everything in one giant context. Every consumer re-renders on any change. Split contexts by domain and by update frequency.
- Creating a new value object on every render. Memoize it or let the React Compiler do it, otherwise all consumers re-render whenever the provider does.
- Using a meaningful default instead of
null. A fake default hides a missing provider. Default tonulland throw a clear error in the custom hook. - Reaching for Context for every shared value. For one or two levels, props are clearer. For nearby siblings, lift state. Try composition before Context.
- Storing server data in Context by hand. Fetching, caching, and refetching are better handled by a data library like TanStack Query.
- Placing the provider too high. If only one page uses the state, put the provider on that page so it resets when the page unmounts and doesn't affect the rest of the app.
Frequently Asked Questions (FAQ) About the React Context API
Prop drilling is passing a prop through several intermediate components that don't use it, just so a deeply nested component can receive it. It's fine for short chains but makes code harder to change when the chain is long.
Not exactly. Context is a way to pass values down the tree. Combined with useState or useReducer, it can handle a lot of app state. Redux adds a single store, selectors that limit re-renders, middleware, and strong DevTools. Small to medium apps often don't need Redux, while large apps with complex shared state may benefit from it.
Usually because the provider passes a new object on every render, or because one context holds values that change at very different rates. Memoize the value and split the context so components only subscribe to what they need.
Use null for contexts that require a provider, such as auth or cart state, and throw an error in your custom hook when it's missing. A real default value makes sense for things that genuinely have one, like a theme defaulting to light.
No. Context is a client feature. In frameworks with Server Components, put the provider in a Client Component and render it from your server layout, passing server-rendered content through as children.
Both read the nearest provider's value. useContext follows the rules of hooks and must be called at the top level. use can be called inside conditions and loops, which helps when a component only needs the context on certain branches.
Conclusion
Context removes prop drilling by letting any component read a value from the nearest provider above it. Wrap each context in a provider component and a custom hook, default to null and throw when the provider is missing, combine it with useReducer for complex state, and split state from dispatch so components only re-render when the data they use changes.
Before you add a context, try composition with children and check whether lifting state to a nearby parent is enough. When you do use Context, keep each one small and focused. If you find yourself fighting re-renders with very frequent updates, that's the signal to look at a store library like Redux Toolkit or Zustand.


