React Context API: Sharing State Without Prop Drilling
A practical guide to React's Context API — createContext, Provider, and useContext — with the cases it solves cleanly and the cases where you should reach for a state library instead.
What you'll learn
- ✓How createContext, Provider, and useContext fit together
- ✓When Context is the right tool (theme, auth, locale, i18n)
- ✓When to reach for a state library like Zustand or Redux instead
- ✓How to avoid the most common Context performance pitfalls
- ✓A pattern for splitting one big context into focused contexts
Prerequisites
- •Comfort with hooks — see useState and useEffect
- •You have hit prop drilling at least once and felt the friction
You build a component tree. A value lives at the top — the current user, the theme, the language — and a leaf five levels down needs it. You start passing it through each layer as a prop. Every intermediate component now declares a prop it does not use. That is prop drilling, and the standard React fix for it is the Context API.
What Context actually is
Context is a way to make a value available to any component below a given point in the tree, without threading it through every intermediate component. You create a context, wrap part of the tree in its Provider, and any descendant can read the current value with useContext.
It is not a state manager. It is a delivery mechanism. The state still has to live somewhere — usually in useState or useReducer inside the provider component.
The three pieces
import { createContext, useContext, useState } from 'react';
// 1. Create the context with a default value
const ThemeContext = createContext('light');
// 2. Wrap part of the tree in a Provider
function App() {
const [theme, setTheme] = useState('light');
return (
<ThemeContext.Provider value={theme}>
<Page />
</ThemeContext.Provider>
);
}
// 3. Read it anywhere below with useContext
function ThemedButton() {
const theme = useContext(ThemeContext);
return <button className={`btn-${theme}`}>Click</button>;
}
That is the whole API. Three steps: create, provide, consume.
The default value passed to createContext is only used when a consumer is rendered outside any matching Provider. In application code that is usually a bug; in tests it can be handy.
A real-world shape: an auth context
A typical context exposes both a value and the functions that update it:
import { createContext, useContext, useState, useCallback } from 'react';
const AuthContext = createContext(null);
export function AuthProvider({ children }) {
const [user, setUser] = useState(null);
const login = useCallback(async (email, password) => {
const res = await fetch('/api/login', {
method: 'POST',
body: JSON.stringify({ email, password }),
});
const data = await res.json();
setUser(data.user);
}, []);
const logout = useCallback(() => setUser(null), []);
const value = { user, login, logout };
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
}
// Custom hook for consumers — friendlier than useContext directly
export function useAuth() {
const ctx = useContext(AuthContext);
if (ctx === null) {
throw new Error('useAuth must be used inside <AuthProvider>');
}
return ctx;
}
Wrap the app once:
<AuthProvider>
<App />
</AuthProvider>
Then anywhere in the tree:
function Header() {
const { user, logout } = useAuth();
return user
? <button onClick={logout}>Sign out {user.name}</button>
: <a href="/login">Sign in</a>;
}
The header does not know or care where AuthProvider lives. It just asks.
When Context is the right tool
Context shines when a value is genuinely global to a subtree and changes infrequently:
- Theme — light/dark, brand colour palette.
- Authenticated user — id, name, roles, sign-in helpers.
- Locale and translations — current language, message catalogues.
- Routing primitives — the current route is shared via context by React Router.
- Form state for a single form —
react-hook-formuses context to share field registration.
Notice the pattern. These are all things that a wide swath of the tree wants to read, but that are updated by a small number of actions: signing in, switching theme, changing language. Low write frequency, broad read.
Try it yourself. Build a ThemeProvider that holds theme and a toggleTheme function in state. Add a button anywhere in the tree that calls toggleTheme, and a <body className={theme}> wrapper that reflects the current theme. Confirm every consumer updates without a single prop being passed down.
When Context is the wrong tool
Context is a poor fit for state that:
- Changes often (every keystroke, every cursor move, scroll position).
- Is read by only a few components close together — lift state to the nearest common parent instead.
- Has complex update logic with derived values, selectors, and cross-slice dependencies.
- Needs time-travel debugging, middleware, or persistence out of the box.
For those, reach for a state management library:
- Zustand — tiny, hook-based, no provider needed. Great default for new apps.
- Jotai — atomic state, fine-grained subscriptions.
- Redux Toolkit — the established choice for large apps, devtools and middleware included.
- TanStack Query — for server state (caching, refetching). Most “context for fetched data” code is better written with this.
The rule of thumb: if you find yourself reaching for useMemo and useCallback everywhere just to keep your context value stable, you have probably outgrown Context.
The performance gotcha
Here is the thing every React developer eventually trips over.
Every consumer of a context re-renders when the context value changes. Not when “their slice” changes — when the value reference changes at all.
function AppProvider({ children }) {
const [user, setUser] = useState(null);
const [theme, setTheme] = useState('light');
// New object every render — every consumer re-renders every render
return (
<AppContext.Provider value={{ user, setUser, theme, setTheme }}>
{children}
</AppContext.Provider>
);
}
A button that only cares about theme will re-render whenever user changes, and vice versa. For a handful of consumers this is fine. For hundreds it is a problem.
Fix 1: split contexts
The simplest and most effective fix is to put unrelated state in separate contexts:
<ThemeProvider>
<AuthProvider>
<App />
</AuthProvider>
</ThemeProvider>
Now a themed-button only subscribes to ThemeContext and is untouched by auth changes.
Fix 2: separate “value” and “actions”
If the setters are stable but the value changes, split them:
const CountValueContext = createContext(0);
const CountActionsContext = createContext(null);
function CountProvider({ children }) {
const [count, setCount] = useState(0);
// actions object never changes — memoise once
const actions = useMemo(() => ({
increment: () => setCount((c) => c + 1),
reset: () => setCount(0),
}), []);
return (
<CountActionsContext.Provider value={actions}>
<CountValueContext.Provider value={count}>
{children}
</CountValueContext.Provider>
</CountActionsContext.Provider>
);
}
A component that only dispatches actions reads CountActionsContext and never re-renders when count changes. A component that displays the count reads CountValueContext.
Fix 3: memoise the value object
If you must put multiple values in one context, memoise the object so its reference is stable:
const value = useMemo(() => ({ user, login, logout }), [user, login, logout]);
Without useMemo, value is a brand new object every render and every consumer re-renders.
Try it yourself. Build a provider that stores count and name in one context. Add two consumers: one reads count, one reads name. Add a console.log to each render. Update name and watch both consumers re-render. Now split the context into two, repeat — only the relevant consumer re-renders.
Patterns that age well
A few habits keep Context code maintainable as the app grows.
Export a hook, not the context. Consumers import useAuth(), not AuthContext. You keep the freedom to change the implementation later (split contexts, swap to Zustand) without rewriting every call site.
Throw if the provider is missing. The null-check pattern in the useAuth example above catches a whole class of “wrapped the wrong subtree” bugs at the moment they occur.
Keep providers thin. A provider that does too much (fetches data, runs effects, manages five pieces of state) becomes a god component. Split it.
Compose providers at the root. It is fine to wrap your app in five providers. It is not fine to have providers scattered randomly through the tree unless there is a real reason.
Context vs lifting state vs a store
A small decision guide:
| Situation | Use |
|---|---|
| Two siblings need the same value | Lift state to the parent |
| A value is needed by many components across the tree, changes rarely | Context |
| State updates every keystroke and is read by many components | A store (Zustand, Redux, Jotai) |
| Data comes from a server and needs caching | TanStack Query or SWR |
| One component needs server data once | A plain useEffect fetch is fine |
You can — and should — combine these. A typical real app uses TanStack Query for server data, Context for auth and theme, and useState for everything local. Reach for a global store only when the simpler tools are clearly straining.
A worked example: locale switching
A small but realistic context that ties everything together:
import { createContext, useContext, useState, useMemo } from 'react';
const messages = {
en: { hello: 'Hello', bye: 'Goodbye' },
fr: { hello: 'Bonjour', bye: 'Au revoir' },
};
const LocaleContext = createContext(null);
export function LocaleProvider({ children }) {
const [locale, setLocale] = useState('en');
const value = useMemo(() => ({
locale,
setLocale,
t: (key) => messages[locale][key] ?? key,
}), [locale]);
return <LocaleContext.Provider value={value}>{children}</LocaleContext.Provider>;
}
export function useLocale() {
const ctx = useContext(LocaleContext);
if (!ctx) throw new Error('useLocale must be used inside <LocaleProvider>');
return ctx;
}
Consumers do not care how t resolves the message — they just call t('hello'). Switching languages re-renders the whole tree exactly once.
Recap
You now know:
- Context is a delivery mechanism, not a state manager. The state lives in a
useState/useReducerinside the provider. - The three pieces are
createContext,Provider, anduseContext— usually wrapped behind a custom hook. - Context fits values that are broad-read, low-write: theme, auth, locale, routing.
- For high-frequency updates, complex selectors, or server state, reach for Zustand, Redux, or TanStack Query instead.
- The big performance pitfall is that every consumer re-renders when the value reference changes — split contexts or memoise the value to fix it.
Next steps
Once you are comfortable with Context, the next natural step is extracting logic into reusable hooks of your own.
→ Next: Building Custom React Hooks
Questions or feedback? Email codeloomdevv@gmail.com.