React context is the official way to share data across a component tree without passing it down by hand as props at every level. If you have ever added a throwaway prop to every component in a chain just to deliver something like the theme, the signed-in user, or the selected language to a component five levels deep, you have hit the problem known as "prop drilling." In this article I show how to set up the Context API correctly, wrap it in a custom hook, avoid the most common performance pitfalls, and recognise when you should not reach for context at all — all with concrete code.
What prop drilling actually is
Suppose session info lives at the top and only a deeply nested Avatar component uses it. The Layout, Sidebar and UserMenu components in between never read the value, yet they are forced to accept a prop just to pass it down:
function App() {
const [user, setUser] = useState(null);
return <Layout user={user} />;
}
// Layout -> Sidebar -> UserMenu -> Avatar
// each one forwards "user" only to the one below it
The issue: intermediate components have their signatures polluted with data they don't depend on, refactoring gets harder, and renaming a single prop ripples through the whole chain. Context short-circuits that chain: you provide the value once at the top and read it directly below.
The smallest working example
Context has three parts: create the context with createContext, broadcast a value through a Provider, and read that value with useContext.
import { createContext, useContext, useState } from "react";
const ThemeContext = createContext("light");
export function ThemeProvider({ children }) {
const [theme, setTheme] = useState("light");
const toggle = () => setTheme(t => (t === "light" ? "dark" : "light"));
return (
<ThemeContext.Provider value={{ theme, toggle }}>
{children}
</ThemeContext.Provider>
);
}
export function useTheme() {
return useContext(ThemeContext);
}
Then wrap the root of your app in the provider and call the hook from any descendant:
function ThemeButton() {
const { theme, toggle } = useTheme();
return <button onClick={toggle}>Theme: {theme}</button>;
}
function App() {
return (
<ThemeProvider>
<ThemeButton />
</ThemeProvider>
);
}
Now, no matter how many layers sit between ThemeButton and App, none of them has to carry a theme prop.
Safe access with a custom hook
Above I wrote a small custom hook called useTheme; always prefer this pattern. It has two benefits: it hides the context's internals from the caller, and it can throw a meaningful error if used without a provider.
export function useTheme() {
const ctx = useContext(ThemeContext);
if (ctx === undefined) {
throw new Error("useTheme must be used inside a <ThemeProvider>");
}
return ctx;
}
If you set the initial value with createContext(undefined), then forgetting to add the provider gives you a clear error instead of silently returning a wrong default. When working in a team, this speeds up debugging considerably.
The biggest performance pitfall: needless re-renders
The most misunderstood thing about context is this: when the provider's value changes, every component consuming that context re-renders. The trouble starts when you create a fresh object reference on every render:
// BAD: a new { } object each render -> all consumers re-render
<ThemeContext.Provider value={{ theme, toggle }}>
Even if the provider component re-renders for some unrelated reason, that line produces a new object, and because React compares references with Object.is, it counts as "changed." The fix is to stabilise the value with useMemo:
const value = useMemo(() => ({ theme, toggle }), [theme]);
return <ThemeContext.Provider value={value}>{children}</ThemeContext.Provider>;
A second technique is to split the context: if you put frequently changing data (e.g. live text input) and rarely changing functions (e.g. dispatch) into two separate contexts, the fast-changing value only affects the components that read it. Rule of thumb: the more unrelated things a single context carries, the higher the risk of wasteful re-renders.
Combining it with useReducer
As state logic grows, using useReducer instead of useState and combining it with context is a clean pattern. It behaves like a small global "store" but needs no external library:
const CartContext = createContext(null);
function cartReducer(state, action) {
switch (action.type) {
case "add": return [...state, action.item];
case "remove": return state.filter(i => i.id !== action.id);
default: return state;
}
}
export function CartProvider({ children }) {
const [items, dispatch] = useReducer(cartReducer, []);
const value = useMemo(() => ({ items, dispatch }), [items]);
return <CartContext.Provider value={value}>{children}</CartContext.Provider>;
}
React keeps the dispatch function stable, so you can safely use it in memo dependency arrays.
When not to use context
Context is not the answer to every problem. In the following situations, look elsewhere:
- High-frequency, broad updates (e.g. thousands of subscribers on every keystroke): context on its own is not a state manager optimised for performance; tools like Zustand, Jotai or Redux Toolkit are more efficient in this scenario.
- Server data (data from an API that needs caching and retries): a data layer like TanStack Query is a far better fit than context for this.
- Only one or two levels deep: if the prop drilling is two layers, passing a prop is often more readable than setting up a context.
Context's sweet spot is data that rarely changes but is read in many places: theme, authentication state, language/localisation, feature flags.
Frequently Asked Questions
Does context replace Redux?
Partly. Context is a "data transport" mechanism, not a full-featured state management library. For simple global state, useReducer + context is enough; but once you need middleware, devtools, or selector-based render optimisation, Redux Toolkit or Zustand earns its place.
Is nesting multiple contexts a problem?
No, it's a common and healthy pattern. Using separate providers for theme, user and cart is usually better for both performance and readability than one giant context. You can keep the root clean by collecting the nested providers into a single AppProviders component.
Is useMemo really necessary?
If the provider value contains an object or function and the provider can re-render, yes. If the value is a primitive (string, number, boolean), React compares the value rather than a reference, and useMemo isn't needed.
Want to simplify the state flow in your React architecture? I can help you build a performant, prop-drilling-free context setup — get in touch.