The React Context API lets you share a value, such as the current user, theme or shopping cart, with every component below a provider, without passing props through each level. For global state management, combine it with useState or useReducer in a provider component, expose the state and the update functions through context, and read them with use() or useContext() wherever they’re needed.
This guide builds a small shopping cart with Context and useReducer in React 19, shows the re-render problem most Context setups have (and how splitting contexts fixed it in my test), and explains when Context is enough and when a library such as Zustand or Redux Toolkit is the better tool. Every example was run in a browser.
The Problem: Prop Drilling
Without Context, data that many components need has to be passed down through every component in between, even those that don’t use it:
<App user={user}>
<Layout user={user}>
<Header user={user}>
<Avatar user={user} /> {/* the only component that needs it */}This “prop drilling” is fine for one or two levels (see passing data between React components), but it gets painful as the tree grows. Context lets Avatar read user directly from the nearest provider above it.
Context Basics in React 19
import { createContext, use, useState } from 'react';
// 1. Create the context (the argument is the default when there's no provider)
const ThemeContext = createContext('light');
// 2. Provide a value
function App() {
const [theme, setTheme] = useState('dark');
return (
<ThemeContext value={theme}>
<Page />
</ThemeContext>
);
}
// 3. Read it anywhere below
function Button() {
const theme = use(ThemeContext); // or useContext(ThemeContext)
return <button className={theme}>Save</button>;
}Two React 19 changes appear here: you render <ThemeContext value={...}> directly (in React 18 it was <ThemeContext.Provider>, which still works), and use() can read context, even inside conditions, unlike useContext(). The old <Context.Consumer> render-prop API and the legacy contextTypes API from class components aren’t needed in new code; the legacy API was removed in React 19.
Global State with Context and useReducer
For state that many components read and update, put the state in a provider component and manage it with a reducer, so every change goes through one function with named actions. This cart provider uses two contexts: one for the cart data and one for the dispatch function:
import { createContext, use, useMemo, useReducer } from 'react';
const CartContext = createContext(null);
const CartDispatchContext = createContext(null);
function cartReducer(items, action) {
switch (action.type) {
case 'added': {
const existing = items.find((i) => i.id === action.product.id);
if (existing) {
return items.map((i) => (i.id === action.product.id ? { ...i, qty: i.qty + 1 } : i));
}
return [...items, { ...action.product, qty: 1 }];
}
case 'removed':
return items.filter((i) => i.id !== action.id);
case 'cleared':
return [];
default:
throw new Error(`Unknown action: ${action.type}`);
}
}
export function CartProvider({ children }) {
const [items, dispatch] = useReducer(cartReducer, []);
const value = useMemo(() => {
const count = items.reduce((sum, i) => sum + i.qty, 0);
const total = items.reduce((sum, i) => sum + i.qty * i.price, 0);
return { items, count, total };
}, [items]);
return (
<CartContext value={value}>
<CartDispatchContext value={dispatch}>{children}</CartDispatchContext>
</CartContext>
);
}
export function useCart() {
const cart = use(CartContext);
if (!cart) throw new Error('useCart must be used inside <CartProvider>');
return cart;
}
export function useCartDispatch() {
const dispatch = use(CartDispatchContext);
if (!dispatch) throw new Error('useCartDispatch must be used inside <CartProvider>');
return dispatch;
}Components then use the custom hooks and never touch the contexts directly:
import { memo } from 'react';
import { CartProvider, useCart, useCartDispatch } from './CartContext';
const products = [
{ id: 'kb', name: 'Keyboard', price: 49 },
{ id: 'ms', name: 'Mouse', price: 19 },
];
function CartBadge() {
const { count } = useCart();
return <span id="badge">Cart ({count})</span>;
}
// Only needs dispatch, so it does not re-render when the cart changes
const ProductCard = memo(function ProductCard({ product }) {
const dispatch = useCartDispatch();
return (
<li>
{product.name} ${product.price}{' '}
<button onClick={() => dispatch({ type: 'added', product })}>Add {product.name}</button>
</li>
);
});
function CartSummary() {
const { items, total } = useCart();
const dispatch = useCartDispatch();
return (
<aside id="summary">
{items.map((i) => (
<p key={i.id}>
{i.name} × {i.qty}{' '}
<button onClick={() => dispatch({ type: 'removed', id: i.id })}>Remove {i.name}</button>
</p>
))}
<strong>Total: ${total}</strong>
</aside>
);
}
export default function Shop() {
return (
<CartProvider>
<header><CartBadge /></header>
<ul>{products.map((p) => <ProductCard key={p.id} product={p} />)}</ul>
<CartSummary />
</CartProvider>
);
}In my test, adding the keyboard twice and the mouse once showed “Cart (3)”, “Keyboard × 2”, “Mouse × 1” and “Total: $117”; removing the keyboard brought the badge back to “Cart (1)”. The custom hooks throw a clear error if a component is used outside the provider, which is easier to debug than a null value.
The Re-render Problem (and How Splitting Context Fixes It)
Every component that reads a context re-renders whenever the context value changes, and memo can’t stop it. The most common setup puts state and functions into one object:
// The common mistake: one context, a new object on every render
import { createContext, memo, use, useState } from 'react';
const AppContext = createContext(null);
function AppProvider({ children }) {
const [count, setCount] = useState(0);
return <AppContext value={{ count, add: () => setCount((c) => c + 1) }}>{children}</AppContext>;
}
const ProductCard = memo(function ProductCard({ name }) {
const { add } = use(AppContext); // only uses add, but re-renders on every count change
return <li><button onClick={add}>Add {name}</button></li>;
});
function Badge() {
const { count } = use(AppContext);
return <span id="badge">Cart ({count})</span>;
}
export default function SingleShop() {
return (
<AppProvider>
<Badge />
<ul><ProductCard name="Keyboard" /><ProductCard name="Mouse" /></ul>
</AppProvider>
);
}Here the provider creates a new object on every render, so every consumer re-renders after every change, including ProductCard, which only needs add. I counted renders in a production build:
| Setup | ProductCard renders on load | After 3 cart updates |
|---|---|---|
One context with { count, add } | 2 | 8 (both cards re-rendered every time) |
| Separate state and dispatch contexts (cart example) | 2 | 2 (no re-renders) |
In the split version, only CartBadge and CartSummary, which read the cart, re-rendered (from 1 to 5 renders each after four updates). Techniques that help:
- Split contexts by how often they change: state vs dispatch, or user vs theme vs cart.
- Memoize the value with
useMemowhen it’s an object, so it only changes when its contents do. - Pass
dispatchor stable functions (fromuseReducer, or wrapped inuseCallback) instead of recreating functions in the value. - Keep context close to where it’s used; not everything has to live at the app root.
The React Compiler can memoize the value object for you, but it doesn’t change the rule that every consumer re-renders when the value changes, so splitting still matters for fast-changing state.
When to Use Context, and When to Use a Library
| Need | Good fit |
|---|---|
| Values that rarely change: theme, locale, signed-in user, feature flags | Context |
| Small to medium app state shared by a few areas (cart, wizard, filters) | Context + useReducer |
| Frequently changing state read by many components; components should re-render only for the slice they use | Zustand (selectors) or Jotai (atoms) |
| Large app, many developers, strict patterns, time-travel debugging | Redux Toolkit (see global state with React Redux) |
| Data from an API: caching, refetching, loading states | TanStack Query (server state isn’t global client state; see fetching data with React hooks) |
| State that belongs in the URL: filters, tabs, pagination | URL search params |
Context is a way to pass a value, not a state manager by itself; the state still lives in useState or useReducer. It works well for global state up to the point where re-renders become a problem, and libraries with selectors exist precisely to solve that. A dark mode toggle is a typical good use, as in my guide to adding dark mode in React.
Best Practices
- Wrap each context in a provider component and custom hooks (
useCart,useCartDispatch) so components don’t import contexts directly. - Throw a helpful error in the hook when there’s no provider.
- Use
useReduceronce the state has several related actions; it keeps update logic testable and in one place. - Split state and dispatch, and split unrelated data into separate contexts.
- Don’t put server data (API responses) in Context if you need caching and refetching; use a data-fetching library.
- Avoid one giant “AppContext” holding everything.
Conclusion
The React Context API removes prop drilling by letting any component read a value from the nearest provider. For global state, keep the state in a provider with useReducer, expose it through separate state and dispatch contexts with small custom hooks, and memoize object values. In my test, that split took unnecessary re-renders from six to zero for components that only dispatch actions. When many components read fast-changing state, switch to Zustand, Jotai or Redux Toolkit, which can re-render only the components that use the changed slice.
FAQs
What is the React Context API used for?
Sharing values such as the current user, theme, locale or a shopping cart with many components without passing props through every level. Combined with useState or useReducer, it can manage global state.
Is React Context a replacement for Redux?
For small and medium apps, often yes. Context plus useReducer covers shared state well. Redux Toolkit or Zustand are better when many components read frequently changing state or the app needs strict patterns and dev tooling.
Why do all my context consumers re-render?
Every component that reads a context re-renders when its value changes, and a new object created on each render counts as a change. Memoize the value, split state and dispatch into separate contexts, and split unrelated data.
What is the difference between use and useContext?
Both read a context value. use() was added in React 19 and can be called inside conditions and loops; useContext() follows the normal rules of hooks.
How do I use Context with useReducer?
Call useReducer in a provider component, put the state in one context and dispatch in another, and expose them through custom hooks. Components dispatch actions such as { type: “added”, product }.
Should I use the Provider component in React 19?
You can render the context directly, for example <ThemeContext value={theme}>. <ThemeContext.Provider> still works.








