Passing props through layers of uninterested components is tedious work. One day you are shipping a feature, and the next you are editing six files just to rename a single prop. That is prop drilling in a nutshell: a parent has data, a deep child needs it, and every component between them becomes a courier. The app still runs, but the codebase turns brittle. Remove a middle layer, and half the tree collapses. Change a type, and TypeScript complains across three directories. React Context API exists to remove those middlemen entirely.
What Prop Drilling Actually Looks Like
Imagine a standard app shell. You have an App component that fetches the current user. Inside App lives a Layout, inside Layout sits a Sidebar, inside Sidebar nests a Navigation, and finally inside Navigation you find the UserAvatar that actually needs the user object.
Your code ends up like this:
function App() {
const user = { name: 'Aarav', role: 'admin' };
return <Layout user={user} />;
}
function Layout({ user }) {
return <Sidebar user={user} />;
}
function Sidebar({ user }) {
return <Navigation user={user} />;
}
function Navigation({ user }) {
return <UserAvatar user={user} />;
}
Layout, Sidebar, and Navigation do nothing with that user object except hand it down. They accumulate props they do not own, their interfaces swell, and testing them requires mocking data they never touch. The real crime is how fast this spreads. Add an isLoggedIn flag, a locale string, or a theme value, and the same parade repeats.
How Context API Changes the Game
Think of Context API like a WiFi router. Instead of running long cables through every room to reach each device, the router sends a signal through the air. Any device in range can connect directly. In React terms, the router is the Provider, the signal is your state or data, and the device is any nested component that calls useContext.
The setup has three moving parts:
React.createContext()builds the data channel.- The Provider wraps a section of your tree and transmits a value.
- The
useContexthook lets descendants receive that value without touching intermediate props.
You still have a tree, but the branches between root and leaf no longer need to agree on a courier contract.
Building a Context from Scratch
Let us build a concrete example with theme settings, since most apps need light or dark mode at some point.
First, create the context object. This is the pipe:
import { createContext, useState, useMemo } from 'react';
const ThemeContext = createContext(null);
export function ThemeProvider({ children }) {
const [theme, setTheme] = useState('light');
return (
<ThemeContext.Provider value={{ theme, setTheme }}>
{children}
</ThemeContext.Provider>
);
}
export default ThemeContext;
Then wrap your application in the provider. Usually this happens near the root:
import { ThemeProvider } from './ThemeContext';
function App() {
return (
<ThemeProvider>
<Layout />
</ThemeProvider>
);
}
Now any descendant can tap the signal directly. Here is a toggle button buried deep inside the UI:
import { useContext } from 'react';
import ThemeContext from './ThemeContext';
function ThemeToggle() {
const { theme, setTheme } = useContext(ThemeContext);
return (
<button
onClick={() => setTheme(prev => prev === 'light' ? 'dark' : 'light')}
>
Current theme: {theme}
</button>
);
}
Notice that Layout, Sidebar, and Navigation never see the theme prop. They render normally, and ThemeToggle grabs what it needs straight from the context. The wiring is invisible from the outside, which is exactly the point.
Where Context Actually Fits
Context works best for data that many distant components share but no single parent owns cleanly. Good candidates include:
- Theme settings such as light or dark mode, accent colors, or font scaling.
- Authentication state like the current user object, login status, or session expiry.
- Language and locale settings for internationalization.
- Shopping cart data that must stay synced across a header badge, a mini-cart dropdown, and a checkout page.
Resist the urge to dump every piece of local state into Context. A form input two levels down does not need a global broadcast. Keep Context for real cross-cutting concerns, and let the rest stay as ordinary props.
Performance Traps and How to Avoid Them
Context is not free. When a context value updates, every component hooked into that context re-renders, even if the value slice they care about did not change. The classic mistake is tossing a fresh object literal into the Provider on every parent render.
In our theme example, every time the ThemeProvider re-renders because its own parent updated, the expression { theme, setTheme } creates a brand new object. React sees a new reference, and every consumer updates. If your theme rarely changes but your app state changes often, you are paying for renders you do not need.
The fix is twofold.
Split your contexts by update frequency. A UserContext that changes once per login should not share a provider with a NotificationContext that updates every few seconds. Keep them separate so static data does not ride the same re-render train as volatile data.
Wrap the value in useMemo when the value is an object or array. Give React a stable reference:
export function ThemeProvider({ children }) {
const [theme, setTheme] = useState('light');
const value = useMemo(() => ({ theme, setTheme }), [theme]);
return (
<ThemeContext.Provider value={value}>
{children}
</ThemeContext.Provider>
);
}
Now the object identity only shifts when theme actually changes. Descendant components that care about the context but are shielded by React.memo further down will skip the work.
Mistakes That Waste Your Time
The two errors that still show up in production code are easy to prevent.
First, forgetting to export the context itself. If you only export the Provider wrapper and keep the context object private, a developer writing a new feature cannot call useContext without refactoring your module. Export the context so consumers can import both the provider and the consumer hook cleanly.
Second, calling useContext outside the corresponding Provider. If ThemeToggle renders in a branch of the tree that is not wrapped in ThemeProvider, the hook returns the default value passed to createContext, or undefined if you passed none. This leads to silent failures like cannot read property of undefined. You can guard against this by assigning a sensible default or throwing a clear error early in the hook call.
Context vs Redux: Keep It Simple
You do not always need Redux. For medium-sized projects, Context paired with useState or useReducer covers the bulk of state sharing. Redux shines when you need time-travel debugging, complex middleware, or global transactions that must roll back in sequence. If your entire state story is a user object, a theme string, and a cart array, a store library adds boilerplate you will never leverage.
That said, Context is not a full state management system on its own. It does not give you a single global snapshot, and it does not batch updates across unrelated contexts. Use it as a replacement for prop drilling, not as an operating system for your entire data layer.
The Real Takeaway
Stop threading props through components that do not care about them. Create focused contexts for the data that actually spans your tree, wrap providers high enough to cover the consumers, and always stabilize the value object when you are passing collections or functions. Context API keeps yourReact code direct: props stay local, global data travels wirelessly, and your component boundaries stay clean.
