React 37 ⚛️ Prop Drilling Problem
Prop drilling is what happens when data has to pass through components that do not need it. A value originates at the top of the tree, and a component five levels down needs it. The components in between — the ones that merely relay the value — have no interest in it. They receive it as a prop, pass it to their child, and that child passes it to its child, until the value finally reaches its destination. The relay components are polluted with props they never read, and every change to the data’s shape ripples through every intermediate layer.
The problem is not that props are bad. Props are React’s fundamental mechanism for passing data down the tree, and they are the right tool for parent-child communication. The problem is that props are a direct channel. They travel one level at a time. When the distance between the source and the consumer is long, the channel becomes a chain of relays, and each relay is a component that has been forced to know about data it does not use.
React offers three answers to this problem, and they are not interchangeable. Component composition restructures the tree so the data does not need to travel as far. Context creates a channel that skips the intermediate levels entirely. External stores move the state outside React and let components subscribe directly to the pieces they need. Each has a cost, and each is the wrong answer in the wrong situation.
This chapter covers three areas. First, why prop drilling exists — what structural conditions create it, and why React’s one-level-at-a-time prop model makes it inevitable in certain tree shapes. Second, how to recognize and measure it — the difference between a prop that is drilled and a prop that is legitimately passed, and the signs that the tree needs restructuring rather than a new API. Third, the three solutions and when each applies — composition, context, and external stores, with the trade-offs that determine which one fits a given case. The chapter ends with a complete example session, a quick reference, best practices, common pitfalls, real-world examples, and diagrams showing the shape of a drilled tree versus a restructured one.
Key point: Prop drilling is not a bug in React — it is a signal that data is traveling through components that do not need it. The fix is either to shorten the distance (composition), to skip the intermediate levels (context), or to remove the state from the tree entirely (external stores) .
Why prop drilling exists
The one-level channel problem. React passes data from parent to child through props. There is no built-in mechanism for a component to pass data to a grandchild directly. If a grandchild needs a value, the child must receive it and pass it down. This is by design: it keeps data flow explicit and traceable. You can read a component’s props and know exactly what it receives. But the design has a consequence. When the source and the consumer are far apart in the tree, every component between them becomes a relay. The explicit data flow becomes explicit everywhere, including in components that have nothing to do with the data .
The intermediate pollution problem. A relay component receives a prop it does not use. It does not read the value, does not transform it, does not render it. It only forwards it. This means the component’s props no longer describe what the component needs — they describe what some descendant needs. The component’s interface is polluted by a dependency it does not have. If the drilled prop is renamed, every relay component must be updated. If the drilled prop is removed, every relay component must be cleaned up. The relay components are coupled to data they do not care about .
The re-render problem. When a drilled prop changes, every component in the chain re-renders. The relay components re-render even though their own output does not change. In a deep tree, this can mean dozens of components re-rendering because a value changed at the top and a component at the bottom needed it. The relay components are not just structurally polluted — they are functionally burdened. They pay the cost of a change they do not participate in .
The refactoring problem. When the data’s shape changes, the change propagates through the entire chain. Renaming a prop, adding a field, removing a field, changing a type — each of these touches every relay component. A change that should be local to the source and the consumer becomes a change that spans the tree. The longer the chain, the more expensive the refactor, and the more places a mistake can hide.
The legitimate-passing distinction. Not every prop passed through multiple levels is drilled. If a component in the middle uses the prop — even partially — the prop is not being drilled through it. A List component that receives an items array and passes each item to a ListItem is not drilling; it is doing its job. The distinction is whether the intermediate component reads the prop or merely forwards it. Drilling is forwarding without reading .
The trade-off. The solutions to prop drilling each have a cost. Composition restructures the tree, which may mean changing where components are defined and who owns what. Context creates an implicit channel, which means a component’s dependencies are no longer visible in its props. External stores move state outside React, which means the React tree is no longer the single source of truth. The question is not “how do I eliminate prop drilling” but “which cost is acceptable for this specific tree.”
a. Recognizing prop drilling
A drilled prop has a specific signature: it appears in a component’s props, but the component’s body never reads it. The component receives it, includes it in its return statement’s child element, and nothing else. The prop is a passenger.
This component is a relay. It receives theme and passes it to Toolbar without using it:
function Header({ theme }) {
return (
<header>
<Logo />
<Toolbar theme={theme} />
</header>
);
}
The Header component does not read theme. It does not use it for styling, logic, or conditional rendering. It only forwards it. If Toolbar did not need theme, Header would not have a theme prop at all. The prop exists solely because a descendant needs it .
The chain continues downward. Toolbar receives theme and passes it to ThemedButton:
function Toolbar({ theme }) {
return (
<div>
<ThemedButton theme={theme} />
</div>
);
}
Toolbar also does not read theme. It is a second relay. The value has now traveled through two components that do not use it. If the tree is deeper, the chain is longer. Each relay adds a layer of coupling without adding a layer of meaning.
The consumer is the component that actually reads the value:
function ThemedButton({ theme }) {
return <button className={theme}>Click</button>;
}
ThemedButton reads theme. It is the destination. Every component between the source and ThemedButton that does not read theme is a relay, and the prop is being drilled through it.
b. Solution one — component composition
The first solution is to restructure the tree so the data does not need to travel as far. If the consumer can be rendered by the source directly — or by a component closer to the source — the intermediate layers disappear from the data’s path.
The technique is to pass the consumer as a children prop, or as a named prop, rather than passing the data. The source component renders the consumer directly, and the intermediate components never see the data at all.
Instead of Header receiving theme and passing it down, the component that owns theme can render ThemedButton itself:
function App() {
const theme = 'dark';
return (
<Header>
<ThemedButton theme={theme} />
</Header>
);
}
Header now receives children instead of theme. It does not know what the children are or what props they carry. It renders them where they belong:
function Header({ children }) {
return (
<header>
<Logo />
<Toolbar>{children}</Toolbar>
</header>
);
}
Toolbar also receives children and renders them:
function Toolbar({ children }) {
return <div>{children}</div>;
}
The theme value never passes through Header or Toolbar. It is passed directly from App to ThemedButton at the point where App creates the element. The intermediate components are decoupled from the data. They render whatever they are given, without knowing what it is .
This is the simplest solution when it applies. It requires no new API, no new concept, and no change to how state works. It requires only that the source component be able to render the consumer — that the tree’s ownership structure allows the element to be created where the data lives.
The limitation is that composition does not always fit. If the consumer must be rendered deep inside a component that also renders other things, or if the tree’s structure is determined by the intermediate components rather than by the source, composition may not be able to place the consumer correctly. In those cases, the data still needs a channel that skips levels.
c. Solution two — context
Context creates a channel that bypasses the intermediate levels entirely. A context provider sits at some level of the tree and makes a value available to every descendant, no matter how deep, without passing it through props.
The context is created once, outside any component:
const ThemeContext = createContext('light');
The provider wraps the part of the tree that needs the value:
function App() {
const [theme, setTheme] = useState('light');
return (
<ThemeContext.Provider value={theme}>
<Header />
</ThemeContext.Provider>
);
}
Any descendant can read the value with useContext, without receiving it as a prop:
function ThemedButton() {
const theme = useContext(ThemeContext);
return <button className={theme}>Click</button>;
}
Header and Toolbar no longer receive theme at all. They are decoupled from the data. The value travels from App to ThemedButton through the context channel, skipping every intermediate component .
Context solves the structural problem, but it introduces a new one. A component that reads from context no longer declares that dependency in its props. You cannot tell by looking at ThemedButton‘s signature that it depends on ThemeContext. The dependency is implicit. This makes the component harder to reuse outside the provider and harder to test without wrapping it. Context trades explicit prop drilling for implicit context dependency .
Context also has a re-render characteristic that matters. When the context value changes, every component that reads that context re-renders. There is no way to subscribe to only part of a context value without splitting the context or using a selector-based approach. A context that carries an object with many fields will re-render every consumer when any field changes.
Complete Example Session
// ============================================
// PART 1: THE DRILLED TREE
// ============================================
// theme originates at App and is needed by ThemedButton,
// five levels down. Every component in between receives
// theme as a prop and passes it down without reading it.
function App() {
const theme = 'dark';
return <Layout theme={theme} />;
}
function Layout({ theme }) {
return (
<div>
<Header theme={theme} />
<Main theme={theme} />
</div>
);
}
function Header({ theme }) {
return (
<header>
<Logo />
<Toolbar theme={theme} />
</header>
);
}
function Toolbar({ theme }) {
return (
<div>
<ThemedButton theme={theme} />
</div>
);
}
function ThemedButton({ theme }) {
return <button className={theme}>Click</button>;
}
// Layout, Header, and Toolbar all receive theme.
// None of them reads it. They are relays.
// The prop is drilled through three components.
// ============================================
// PART 2: IDENTIFYING THE RELAYS
// ============================================
// A relay component has a drilled prop if:
// 1. It receives the prop
// 2. It does not read the prop in its body
// 3. It passes the prop to a child element
//
// Layout: receives theme, does not read, passes to Header
// Header: receives theme, does not read, passes to Toolbar
// Toolbar: receives theme, does not read, passes to ThemedButton
// ThemedButton: receives theme, READS it — this is the consumer
//
// Three relays, one consumer.
// ============================================
// PART 3: SOLUTION ONE — COMPOSITION
// ============================================
// Move the element creation to where the data lives.
// App creates ThemedButton directly and passes it as
// children. The intermediate components never see theme.
function App() {
const theme = 'dark';
return (
<Layout>
<Header>
<Toolbar>
<ThemedButton theme={theme} />
</Toolbar>
</Header>
</Layout>
);
}
// Now Layout, Header, and Toolbar receive children
// instead of theme. They render the children without
// knowing what props they carry.
function Layout({ children }) {
return <div>{children}</div>;
}
function Header({ children }) {
return (
<header>
<Logo />
{children}
</header>
);
}
function Toolbar({ children }) {
return <div>{children}</div>;
}
function ThemedButton({ theme }) {
return <button className={theme}>Click</button>;
}
// ============================================
// PART 4: THE LIMITATION OF COMPOSITION
// ============================================
// Composition works when the source can create the
// consumer. It fails when the intermediate component
// must control how the consumer is rendered — for
// example, when it needs to interleave the consumer
// with other elements in a way the source cannot know.
// If Toolbar must render ThemedButton in a specific
// slot alongside other buttons it creates itself,
// App cannot pass ThemedButton as a single child.
function Toolbar() {
return (
<div>
<ToolbarButton label="Cut" />
{/* ThemedButton must go here, but App cannot place it */}
<ToolbarButton label="Paste" />
</div>
);
}
// ============================================
// PART 5: SOLUTION TWO — CONTEXT
// ============================================
// Create a context. The provider sits at App and makes
// theme available to every descendant without props.
import { createContext, useContext } from 'react';
const ThemeContext = createContext('light');
function App() {
const [theme, setTheme] = useState('dark');
return (
<ThemeContext.Provider value={theme}>
<Layout />
</ThemeContext.Provider>
);
}
// Layout, Header, and Toolbar no longer receive theme.
// They render their children normally.
function Layout() {
return (
<div>
<Header />
<Main />
</div>
);
}
function Header() {
return (
<header>
<Logo />
<Toolbar />
</header>
);
}
function Toolbar() {
return (
<div>
<ToolbarButton label="Cut" />
<ThemedButton />
<ToolbarButton label="Paste" />
</div>
);
}
// ============================================
// PART 6: READING FROM CONTEXT
// ============================================
// The consumer reads the value with useContext.
// It does not receive theme as a prop.
function ThemedButton() {
const theme = useContext(ThemeContext);
return <button className={theme}>Click</button>;
}
// The theme value travels from App to ThemedButton
// through the context channel. It skips Layout,
// Header, and Toolbar entirely.
// ============================================
// PART 7: THE IMPLICIT DEPENDENCY
// ============================================
// ThemedButton now depends on ThemeContext, but its
// props do not show it. This is the cost of context.
//
// To use ThemedButton outside the provider, you must
// wrap it:
//
// <ThemeContext.Provider value="light">
// <ThemedButton />
// </ThemeContext.Provider>
//
// Without the provider, ThemedButton uses the default
// value passed to createContext.
// ============================================
// PART 8: THE RE-RENDER CHARACTERISTIC
// ============================================
// When the context value changes, every consumer
// re-renders. If the context value is an object,
// every field change re-renders every consumer.
//
// const AppContext = createContext();
//
// function App() {
// const [user, setUser] = useState(null);
// const [theme, setTheme] = useState('light');
// return (
// <AppContext.Provider value={{ user, theme }}>
// ...
// </AppContext.Provider>
// );
// }
//
// Changing theme re-renders every component that
// reads AppContext, even if it only uses user.
// To avoid this, split the context or use a selector.
// ============================================
// PART 9: SOLUTION THREE — EXTERNAL STORES
// ============================================
// A third option is to move the state outside React
// entirely. A store holds the state, and components
// subscribe to the slices they need.
//
// The store is a plain JavaScript object with
// subscribe and getSnapshot methods.
function createStore(initialState) {
let state = initialState;
const listeners = new Set();
return {
getState: () => state,
setState: (next) => {
state = next;
listeners.forEach((l) => l());
},
subscribe: (listener) => {
listeners.add(listener);
return () => listeners.delete(listener);
},
};
}
// Components read from the store with useSyncExternalStore.
function useStoreSlice(store, selector) {
return useSyncExternalStore(
store.subscribe,
() => selector(store.getState()),
() => selector(store.getState())
);
}
// ============================================
// PART 10: THE STORE IN USE
// ============================================
const themeStore = createStore({ theme: 'light' });
function ThemedButton() {
const theme = useStoreSlice(themeStore, (s) => s.theme);
return <button className={theme}>Click</button>;
}
// ThemedButton subscribes directly to themeStore.
// No provider, no props, no context. The component
// re-renders only when the selected slice changes.
// Layout, Header, and Toolbar are untouched.
// The state lives outside React. The React tree
// is no longer the source of truth.
The ten parts show the drilled tree, the three solutions, and the trade-offs of each: composition restructures, context skips levels but hides dependencies, and external stores move state out of React entirely.
Quick Reference
Signs of Prop Drilling
| Sign | What It Means |
|---|---|
| Component receives a prop it never reads | The prop is being drilled through it |
| Prop appears in a child element but not in the body | The component is a relay |
| Renaming a prop requires changing many files | The prop is drilled across many layers |
| Component re-renders when an unrelated prop changes | The drilled prop is causing extra renders |
The Three Solutions
| Solution | Mechanism | Cost |
|---|---|---|
| Composition | Pass elements, not data | Tree must allow source to create consumer |
| Context | Provider + useContext | Implicit dependency; re-renders all consumers |
| External store | useSyncExternalStore + selector | State outside React; subscription management |
When to Use Each
| Situation | Solution |
|---|---|
| Source can render the consumer | Composition |
| Consumer is deep and tree structure is fixed | Context |
| Many consumers need different slices | External store |
| Only one consumer and distance is short | Props are fine |
Context vs External Store
| Aspect | Context | External Store |
|---|---|---|
| Where state lives | React component | Outside React |
| How consumers read | useContext | useSyncExternalStore |
| Re-render scope | All consumers of the context | Only subscribers of the changed slice |
| Provider required | Yes | No |
| Dependency visible in props | No | No |
The Decision Path
| Question | If yes | If no |
|---|---|---|
| Does an intermediate component read the prop? | Not drilling | Drilling |
| Can the source render the consumer? | Use composition | Continue |
| Do many components need different slices? | Use external store | Use context |
| Is the tree deep and structure fixed? | Use context | Props are fine |
Best Practices
✅ Do This:
// Pass elements as children to avoid drilling
function Layout({ children }) { return <div>{children}</div>; } // ✅
// Create the consumer where the data lives
<Layout><ThemedButton theme={theme} /></Layout> // ✅
// Use context for values many descendants need
const ThemeContext = createContext('light'); // ✅
// Split context by concern to limit re-renders
const UserContext = createContext(null); // ✅
const ThemeContext = createContext('light'); // ✅
// Use selectors with external stores
useStoreSlice(store, (s) => s.theme); // ✅
// Keep context values stable when possible
const value = useMemo(() => ({ user, theme }), [user, theme]); // ✅
// Name context after what it provides
const ThemeContext = createContext('light'); // ✅
❌ Don’t Do This:
// Don't pass data through components that don't read it
function Header({ theme }) { return <Toolbar theme={theme} />; } // ❌
// Don't use context for everything
const EverythingContext = createContext(); // ❌
// Don't put unrelated values in one context
<AppContext.Provider value={{ user, theme, cart }}> // ❌
// Don't create context without a default
const ThemeContext = createContext(); // ❌
// Don't read context in a component that could receive props
function Button() { const theme = useContext(ThemeContext); } // ❌
// Don't use an external store for local component state
const store = createStore({ isOpen: false }); // ❌
// Don't forget the selector causes re-renders
useStoreSlice(store, (s) => s); // ❌
Common Pitfalls
| Pitfall | Why It Happens | Fix |
|---|---|---|
| Drilling through many layers | Tree structure forces data through relays | Use composition or context |
| Context re-renders too much | One context holds many unrelated values | Split context by concern |
| Context dependency invisible | Component reads context not shown in props | Document or wrap with a Hook |
| Composition doesn’t fit | Intermediate component controls placement | Use context instead |
| Store subscription leaks | Not unsubscribing on unmount | useSyncExternalStore handles cleanup |
| Context default masks missing provider | Default value is used silently | Throw if context is null |
| Over-using context | Reaching for context for every shared value | Use props when distance is short |
| Re-rendering relays | Drilled prop changes at the top | Restructure or use a store |
Real-World Examples
1. Theme Through Composition
function App() {
const theme = 'dark';
return <Layout><Button theme={theme} /></Layout>;
}
2. Theme Through Context
const ThemeContext = createContext('light');
function Button() {
const theme = useContext(ThemeContext);
return <button className={theme}>Click</button>;
}
3. Split Contexts
const UserContext = createContext(null);
const ThemeContext = createContext('light');
// Separate providers, separate consumers, separate re-renders
4. Context with a Custom Hook
function useTheme() {
const theme = useContext(ThemeContext);
if (theme === null) throw new Error('Missing ThemeProvider');
return theme;
}
5. External Store with Selector
function useTheme() {
return useSyncExternalStore(
themeStore.subscribe,
() => themeStore.getState().theme
);
}
6. Stable Context Value
const value = useMemo(() => ({ user, setUser }), [user]);
return <UserContext.Provider value={value}>{children}</UserContext.Provider>;
7. Children as a Slot
function Toolbar({ children }) {
return <div>{children}</div>;
}
8. Named Slot Prop
function Layout({ header, main }) {
return <div>{header}<main>{main}</main></div>;
}
9. Context Provider at the Top
function Root() {
return (
<ThemeContext.Provider value="dark">
<App />
</ThemeContext.Provider>
);
}
10. Selector Limits Re-renders
const theme = useStoreSlice(store, (s) => s.theme);
// Re-renders only when s.theme changes, not when other fields change
Visual
The Drilled Tree
┌──────────────────────────────────────────────────────────────┐
│ PROP DRILLING │
│ │
│ App theme = 'dark' │
│ │ │
│ ▼ │
│ Layout ── receives theme ── does NOT read ──► relay │
│ │ │
│ ▼ │
│ Header ── receives theme ── does NOT read ──► relay │
│ │ │
│ ▼ │
│ Toolbar ── receives theme ── does NOT read ──► relay │
│ │ │
│ ▼ │
│ ThemedButton ── receives theme ── READS it ──────► consumer │
│ │
│ Three relays. The prop travels through components │
│ that have no use for it. Each relay is coupled to data │
│ it does not read. │
│ │
└──────────────────────────────────────────────────────────────┘
Composition Shortens the Path
┌──────────────────────────────────────────────────────────────┐
│ COMPOSITION │
│ │
│ App theme = 'dark' │
│ │ │
│ │ creates the element directly: │
│ │ <Layout><Header><Toolbar> │
│ │ <ThemedButton theme={theme} /> │
│ │ </Toolbar></Header></Layout> │
│ │ │
│ ▼ │
│ Layout ── receives children ── renders them ──► pass │
│ │ │
│ ▼ │
│ Header ── receives children ── renders them ──► pass │
│ │ │
│ ▼ │
│ Toolbar ── receives children ── renders them ──► pass │
│ │ │
│ ▼ │
│ ThemedButton ── receives theme directly from App ──► reads │
│ │
│ The intermediate components receive children, not theme. │
│ They do not know what the children are. │
│ │
└──────────────────────────────────────────────────────────────┘
Context Skips the Levels
┌──────────────────────────────────────────────────────────────┐
│ CONTEXT │
│ │
│ <ThemeContext.Provider value="dark"> │
│ │ │
│ ▼ │
│ Layout ── no theme prop ──────────────────────► pass │
│ │ │
│ ▼ │
│ Header ── no theme prop ──────────────────────► pass │
│ │ │
│ ▼ │
│ Toolbar ── no theme prop ──────────────────────► pass │
│ │ │
│ ▼ │
│ ThemedButton ── useContext(ThemeContext) ──────────► reads │
│ │
│ The value travels through the context channel, skipping │
│ Layout, Header, and Toolbar. Those components are fully │
│ decoupled from theme. │
│ │
│ Cost: ThemedButton's dependency on ThemeContext is not │
│ visible in its props. │
│ │
└──────────────────────────────────────────────────────────────┘
The Decision Path
┌──────────────────────────────────────────────────────────────┐
│ CHOOSING A SOLUTION │
│ │
│ Is the prop read by intermediate components? │
│ │ │
│ ├── Yes ──► Not drilling. Leave it. │
│ │ │
│ └── No ──► Can the source render the consumer? │
│ │ │
│ ├── Yes ──► COMPOSITION │
│ │ Pass elements, not data. │
│ │ │
│ └── No ──► Do many consumers need different │
│ slices of the same state? │
│ │ │
│ ├── Yes ──► EXTERNAL STORE │
│ │ Selector subscriptions. │
│ │ │
│ └── No ──► CONTEXT │
│ Provider + useContext. │
│ │
│ Each solution has a cost. Choose the cost that fits │
│ the tree. │
│ │
└──────────────────────────────────────────────────────────────┘
Summary
| Item | Value |
|---|---|
| Prop drilling | Passing data through components that do not read it |
| Relay component | Receives a prop, does not read it, forwards it |
| Consumer | The component that actually reads the prop |
| Composition | Pass elements instead of data to shorten the path |
| Context | Provider + useContext to skip intermediate levels |
| External store | State outside React with selector-based subscriptions |
| Context cost | Implicit dependency; re-renders all consumers |
| Store cost | State outside React; subscription management |
| Composition cost | Tree must allow source to create consumer |
| The real question | Which cost is acceptable for this tree |
Key takeaways:
- Drilling is a structural signal, not a bug. When a prop passes through a component that does not read it, the tree is shaped in a way that forces data through relays. The fix is structural, not a new API.
- Composition is the cheapest fix. Passing elements instead of data requires no new concept and no new API. It works whenever the source can create the consumer.
- Context skips levels but hides dependencies. A component that reads context no longer shows that dependency in its props. This makes it harder to reuse and test outside the provider.
- Context re-renders all consumers. When the context value changes, every component that reads it re-renders. Splitting context by concern limits the blast radius.
- External stores move state out of React. Components subscribe to slices with selectors, so only the subscribers of a changed slice re-render. The cost is that React is no longer the source of truth.
- The legitimate-passing distinction matters. A prop passed through a component that reads it is not being drilled. The relay test is whether the component reads the value.
- Not every drilled prop needs a solution. If the distance is short and the tree is stable, props may be the simplest and most explicit option.
- The decision is about cost. Composition costs tree flexibility. Context costs explicitness. Stores cost React ownership. Choose the cost that fits.
Remember: Prop drilling is what happens when data travels through components that have no use for it. The problem is not the props — it is the distance and the relays. React offers three answers: composition shortens the path, context skips the levels, and external stores remove the state from the tree. Each answer has a cost, and the right choice depends on the shape of the tree and the number of consumers. The goal is not to eliminate prop drilling everywhere but to recognize when the relays are adding coupling without adding meaning, and to choose the solution whose cost is acceptable for that specific structure.
Stop using slow, ad-bloated tool sites! 🤮
🔎 Search “KandZ Tools” on Google to use many professional utilities for free.
KandZ.me is the ultimate minimalist hub for:
✅ Finance (Mortgage, Interest, Inflation)
✅ Tech (Base64, JSON, Dev Suite, IP)
✅ Health (BMI, BMR, TDEE)
✅ Productivity (Timer, Workspace, QR)
⚡️ Fast & Private
🔒 No data leaves your device
💎 100% Free
🔗 Use it now: https://tools.kandz.me
🔖 Bookmark it—you’ll need it later!