React Context API: Patterns and Performance (2026)
React Context is the built-in solution for passing data through the component tree without prop drilling. Used correctly it solves real problems with zero dependencies. Used incorrectly it causes mysterious performance regressions. This guide covers every practical Context pattern — from the basics to compound components and the performance traps you need to avoid.
Table of Contents
createContext and useContext Basics
Context has three moving parts: the context object created with createContext, a Provider component that supplies the value, and the useContext hook that reads it in any descendant component.
// context/AuthContext.tsx
import { createContext, useContext, useState, ReactNode } from 'react';
interface User {
id: string;
name: string;
email: string;
role: 'admin' | 'user';
}
interface AuthContextValue {
user: User | null;
login: (user: User) => void;
logout: () => void;
isAuthenticated: boolean;
}
// Provide undefined as default — we'll guard against this in the hook
const AuthContext = createContext<AuthContextValue | undefined>(undefined);
// Custom hook that throws if used outside the provider
export function useAuth(): AuthContextValue {
const ctx = useContext(AuthContext);
if (!ctx) throw new Error('useAuth must be used within AuthProvider');
return ctx;
}
export function AuthProvider({ children }: { children: ReactNode }) {
const [user, setUser] = useState<User | null>(null);
const login = (user: User) => setUser(user);
const logout = () => setUser(null);
return (
<AuthContext.Provider value={{ user, login, logout, isAuthenticated: user !== null }}>
{children}
</AuthContext.Provider>
);
}
// Consuming component — no prop drilling needed
function NavBar() {
const { user, logout, isAuthenticated } = useAuth();
return (
<nav>
{isAuthenticated ? (
<><span>{user?.name}</span> <button onClick={logout}>Logout</button></>
) : (
<a href="/login">Login</a>
)}
</nav>
);
}
useAuth() hook that wraps useContext and throws a clear error when called outside the provider. This produces a useful error message at development time instead of a cryptic undefined crash.Context + useReducer as Mini Redux
For complex state with multiple related values and transitions, pair Context with useReducer. This gives you the dispatch/action pattern from Redux without any dependency. It scales to surprisingly complex state before you need a library.
// context/ShoppingContext.tsx
import { createContext, useContext, useReducer, ReactNode } from 'react';
interface CartItem { id: string; name: string; price: number; quantity: number; }
interface State {
items: CartItem[];
isOpen: boolean;
}
type Action =
| { type: 'ADD_ITEM'; payload: CartItem }
| { type: 'REMOVE_ITEM'; payload: string }
| { type: 'UPDATE_QTY'; payload: { id: string; quantity: number } }
| { type: 'TOGGLE_CART' }
| { type: 'CLEAR_CART' };
function reducer(state: State, action: Action): State {
switch (action.type) {
case 'ADD_ITEM': {
const existing = state.items.find((i) => i.id === action.payload.id);
if (existing) {
return {
...state,
items: state.items.map((i) =>
i.id === action.payload.id ? { ...i, quantity: i.quantity + 1 } : i
),
};
}
return { ...state, items: [...state.items, action.payload] };
}
case 'REMOVE_ITEM':
return { ...state, items: state.items.filter((i) => i.id !== action.payload) };
case 'UPDATE_QTY':
return {
...state,
items: state.items.map((i) =>
i.id === action.payload.id ? { ...i, quantity: action.payload.quantity } : i
),
};
case 'TOGGLE_CART':
return { ...state, isOpen: !state.isOpen };
case 'CLEAR_CART':
return { ...state, items: [] };
default:
return state;
}
}
interface ShoppingContextValue {
state: State;
dispatch: React.Dispatch<Action>;
totalItems: number;
totalPrice: number;
}
const ShoppingContext = createContext<ShoppingContextValue | undefined>(undefined);
export function useShoppingCart() {
const ctx = useContext(ShoppingContext);
if (!ctx) throw new Error('useShoppingCart must be inside ShoppingProvider');
return ctx;
}
export function ShoppingProvider({ children }: { children: ReactNode }) {
const [state, dispatch] = useReducer(reducer, { items: [], isOpen: false });
const totalItems = state.items.reduce((sum, i) => sum + i.quantity, 0);
const totalPrice = state.items.reduce((sum, i) => sum + i.price * i.quantity, 0);
return (
<ShoppingContext.Provider value={{ state, dispatch, totalItems, totalPrice }}>
{children}
</ShoppingContext.Provider>
);
}
The Re-render Problem and Solutions
Every consumer of a Context re-renders whenever the context value changes — even if the specific piece of data it reads hasn't changed. This is the most common Context performance pitfall.
// Problem: a new object is created on every render, causing all consumers to re-render
function BadProvider({ children }: { children: ReactNode }) {
const [user, setUser] = useState(null);
const [theme, setTheme] = useState('dark');
return (
// New object reference every render = all consumers re-render
<MyContext.Provider value={{ user, theme, setUser, setTheme }}>
{children}
</MyContext.Provider>
);
}
// Fix 1: useMemo to stabilize the value object
import { useMemo } from 'react';
function BetterProvider({ children }: { children: ReactNode }) {
const [user, setUser] = useState(null);
const [theme, setTheme] = useState('dark');
const value = useMemo(
() => ({ user, theme, setUser, setTheme }),
[user, theme] // only new object when these change
);
return <MyContext.Provider value={value}>{children}</MyContext.Provider>;
}
useMemo on the context value only helps if you're NOT splitting contexts (see next section). Splitting contexts is the more scalable fix — useMemo is a band-aid when splitting isn't practical.Splitting Contexts for Performance
The correct solution to context re-render problems is splitting the context by update frequency. Values that change together stay together; values that change independently live in separate contexts.
// Split into state and dispatch — dispatch NEVER changes so components
// that only dispatch will never re-render due to state changes.
const CartStateContext = createContext<State | undefined>(undefined);
const CartDispatchContext = createContext<React.Dispatch<Action> | undefined>(undefined);
export function CartProvider({ children }: { children: ReactNode }) {
const [state, dispatch] = useReducer(reducer, { items: [], isOpen: false });
return (
<CartDispatchContext.Provider value={dispatch}>
<CartStateContext.Provider value={state}>
{children}
</CartStateContext.Provider>
</CartDispatchContext.Provider>
);
}
// Hook that only reads state — re-renders when state changes
export function useCartState() {
const ctx = useContext(CartStateContext);
if (!ctx) throw new Error('Must be inside CartProvider');
return ctx;
}
// Hook that only dispatches — NEVER re-renders due to state changes
export function useCartDispatch() {
const ctx = useContext(CartDispatchContext);
if (!ctx) throw new Error('Must be inside CartProvider');
return ctx;
}
// AddToCartButton only uses dispatch — doesn't re-render when cart items change
function AddToCartButton({ item }: { item: CartItem }) {
const dispatch = useCartDispatch(); // stable reference, never re-renders
return (
<button onClick={() => dispatch({ type: 'ADD_ITEM', payload: item })}>
Add to Cart
</button>
);
}
Compound Components Pattern
Compound components share implicit state via Context to create a flexible, composable API — like how native <select> and <option> work together. The parent owns the state; children read and update it through Context without props.
// components/Tabs/index.tsx
import { createContext, useContext, useState, ReactNode } from 'react';
interface TabsContextValue {
activeTab: string;
setActiveTab: (id: string) => void;
}
const TabsContext = createContext<TabsContextValue | undefined>(undefined);
function useTabs() {
const ctx = useContext(TabsContext);
if (!ctx) throw new Error('Tab components must be used inside <Tabs>');
return ctx;
}
// Parent component — owns state
function Tabs({ children, defaultTab }: { children: ReactNode; defaultTab: string }) {
const [activeTab, setActiveTab] = useState(defaultTab);
return (
<TabsContext.Provider value={{ activeTab, setActiveTab }}>
<div className="tabs">{children}</div>
</TabsContext.Provider>
);
}
// Tab button — reads and updates context
function Tab({ id, children }: { id: string; children: ReactNode }) {
const { activeTab, setActiveTab } = useTabs();
return (
<button
role="tab"
aria-selected={activeTab === id}
onClick={() => setActiveTab(id)}
className={`tab-btn ${activeTab === id ? 'active' : ''}`}
>
{children}
</button>
);
}
// Tab panel — only renders when its id is active
function TabPanel({ id, children }: { id: string; children: ReactNode }) {
const { activeTab } = useTabs();
if (activeTab !== id) return null;
return <div role="tabpanel" className="tab-panel">{children}</div>;
}
// Attach as static properties for a clean API
Tabs.Tab = Tab;
Tabs.Panel = TabPanel;
// Usage — the consumer controls the structure, not the library
function DocsPage() {
return (
<Tabs defaultTab="overview">
<div role="tablist">
<Tabs.Tab id="overview">Overview</Tabs.Tab>
<Tabs.Tab id="api">API Reference</Tabs.Tab>
<Tabs.Tab id="examples">Examples</Tabs.Tab>
</div>
<Tabs.Panel id="overview"><p>Overview content</p></Tabs.Panel>
<Tabs.Panel id="api"><p>API reference content</p></Tabs.Panel>
<Tabs.Panel id="examples"><p>Examples content</p></Tabs.Panel>
</Tabs>
);
}
Theme Context with TypeScript
// context/ThemeContext.tsx
import { createContext, useContext, useState, useEffect, ReactNode } from 'react';
type Theme = 'light' | 'dark' | 'system';
interface ThemeContextValue {
theme: Theme;
resolvedTheme: 'light' | 'dark'; // what's actually applied
setTheme: (theme: Theme) => void;
}
const ThemeContext = createContext<ThemeContextValue | undefined>(undefined);
export function useTheme() {
const ctx = useContext(ThemeContext);
if (!ctx) throw new Error('useTheme must be inside ThemeProvider');
return ctx;
}
export function ThemeProvider({ children }: { children: ReactNode }) {
const [theme, setThemeState] = useState<Theme>(() => {
if (typeof window === 'undefined') return 'system';
return (localStorage.getItem('theme') as Theme) ?? 'system';
});
const getResolved = (t: Theme): 'light' | 'dark' => {
if (t !== 'system') return t;
return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
};
const [resolvedTheme, setResolvedTheme] = useState<'light' | 'dark'>(() => getResolved(theme));
const setTheme = (newTheme: Theme) => {
setThemeState(newTheme);
localStorage.setItem('theme', newTheme);
setResolvedTheme(getResolved(newTheme));
document.documentElement.setAttribute('data-theme', getResolved(newTheme));
};
useEffect(() => {
// Listen for OS theme changes when theme === 'system'
const mq = window.matchMedia('(prefers-color-scheme: dark)');
const handler = () => { if (theme === 'system') setResolvedTheme(getResolved('system')); };
mq.addEventListener('change', handler);
return () => mq.removeEventListener('change', handler);
}, [theme]);
return (
<ThemeContext.Provider value={{ theme, resolvedTheme, setTheme }}>
{children}
</ThemeContext.Provider>
);
}
Context vs Zustand: When to Reach for a Library
| Scenario | Use Context | Use Zustand |
|---|---|---|
| Theme, locale, auth user | Yes — changes rarely, every component needs it | Overkill |
| Shopping cart | Fine for small apps | Better for complex cart logic |
| Frequently updating state (e.g. mouse position) | No — causes widespread re-renders | Yes — selectors isolate updates |
| State shared across routes (without re-mounting) | Yes if Provider wraps router | Yes — store survives unmounts |
| State accessed outside React (e.g. in a fetch utility) | No | Yes — useCartStore.getState() |
| Complex computed/derived state | Possible with useMemo | Cleaner with selector pattern |
FAQ
Does wrapping the Provider value in useMemo actually prevent re-renders?
It prevents the context value reference from changing on every render, which prevents unnecessary re-renders in consumers. However, if any value in the memo dependencies changes, the object reference changes and all consumers re-render. The real fix for granular re-render control is splitting contexts by update frequency, not memoizing a combined value object.
Can I have multiple Providers of the same Context type?
Yes. React always reads from the nearest Provider in the tree. This is useful for scoping state: a modal can have its own Provider that shadows the outer one, or a test can wrap a component in a Provider with mock values without affecting the rest of the app.
What is the Context selector problem?
React Context has no built-in selector mechanism. When you call useContext(MyContext), the component subscribes to the entire context value. Even if you only use context.user.name, the component re-renders when context.theme changes. The solutions are: split the context so each concern lives in its own context, or migrate to Zustand which supports useStore(state => state.user.name) subscriptions.
When should I use the compound components pattern vs render props?
Compound components (Context-based) are preferred in 2026. They produce cleaner JSX because the consumer writes normal component composition instead of function-as-child patterns. Render props are still useful when you need to inject the shared state into a non-React location, or when building headless UI primitives that need to support multiple render strategies.
Is createContext expensive? Should I minimize how many contexts I create?
No. createContext is cheap — it creates a small object. You can create as many contexts as makes architectural sense. Splitting one large context into several focused ones is better than having a single monolithic context, both for performance and for code organization.