What is prop drilling and how to avoid it
Prop drilling happens when data travels through component layers as props, even though intermediate components don't use it.
Theory
TL;DR
- Analogy: you pass a note through five people to reach one person at the end. Everyone in the middle holds it but reads nothing.
- Main problem: intermediate components get polluted with props they don't care about, making refactoring painful.
- Decision rule: 1-2 layers deep is fine. At 3+ layers, Context or a state manager is worth it.
- Context skips intermediates entirely. State managers (Redux, Zustand) go further for app-wide logic.
- Drilling creates coupling. Adding a new prop means updating every component in the chain.
Quick example
// Prop drilling: user tunnels through Parent which ignores it
function App() {
const user = { name: 'John' };
return <Parent user={user} />; // passes down
}
function Parent({ user }) { // receives, doesn't use
return <Child user={user} />; // must forward anyway
}
function Child({ user }) {
return <p>Hello, {user.name}!</p>; // finally uses it
}Parent has no reason to know about user. But it does, because Child needs it. That is prop drilling.
Why it gets painful
At 2 layers, no one cares. At 4-5 layers, renaming a prop means touching every component in the chain. Add a new feature flag that GrandChild needs, and now you update App, Layout, Sidebar, and Panel just to thread one boolean through.
The real cost is coupling. Components that don't use a prop still depend on it structurally. Test one, and you have to mock props that component doesn't even care about.
When to use what
- 1-2 levels deep: keep props, explicit data flow is a feature here
- Theme, locale, auth data across 3+ components: Context API
- Complex state with async actions or persistence: Redux Toolkit or Zustand
- Frequent updates across many consumers: state manager over Context, to avoid mass re-renders
Comparison table
| Aspect | Prop Drilling | React Context | Redux/Zustand |
|---|---|---|---|
| Data flow | Manual at every level | Provider wraps subtree | Global store + selectors |
| Re-renders | Only direct children | All consumers on value change | Controlled via useSelector |
| Boilerplate | None initially, grows fast | Provider + useContext | Actions/reducers (simpler in RTK) |
| Testing | Prop mocking straightforward | Context wrapper needed | Store isolation |
| Best for | Flat trees (<3 levels) | UI state, medium apps | Large apps, async logic |
How React handles this internally
React builds a fiber tree top-down. Prop drilling adds unnecessary data to intermediate fiber nodes, and every level in the chain carries it through reconciliation. Context works differently: it uses a special Context fiber that caches the current value and notifies only consumers via a separate dispatcher queue. Components that don't call useContext skip the update entirely.
Fixing drilling with Context
const CartContext = createContext();
function Dashboard() {
const [cartTotal, setCartTotal] = useState(0);
return (
<CartContext.Provider value={{ cartTotal, setCartTotal }}>
<Header />
</CartContext.Provider>
);
}
function Header() {
return <Sidebar />; // no cartTotal prop needed
}
function Sidebar() {
return <CartWidget />; // no cartTotal prop needed
}
function CartWidget() {
const { cartTotal } = useContext(CartContext);
return <span>{cartTotal} items</span>;
}Header and Sidebar are clean. They don't know cartTotal exists. CartWidget pulls it directly.
Common mistakes
Forgetting to forward a new prop during refactoring
// Added newFeature to App, forgot the path to Child
<Parent user={user} newFeature={true} />
function Parent({ user }) {
return <Child user={user} />; // newFeature silently missing
}Child gets undefined. This is the classic refactoring bug with deep drilling. With Context, consumers declare what they need independently, so adding a value to the provider doesn't require touching intermediate components.
Putting everything in one top-level Context
<UserContext.Provider value={user}>
<EntireApp />
</UserContext.Provider>Every useContext(UserContext) consumer re-renders when user changes. For an app with dozens of consumers, that adds up fast. Scope your providers closer to where data is actually needed.
No default value in createContext
const Ctx = createContext(); // no default
function Consumer() {
const val = useContext(Ctx); // crashes outside Provider
}If Consumer ever renders outside a Provider (in tests, portals, or lazy-loaded routes) you get a runtime error. Fix: createContext({}) or createContext(null) with a guard.
Object value without memoization
function App() {
const [theme, setTheme] = useState('light');
return (
// new object every render = all consumers update
<ThemeContext.Provider value={{ theme, setTheme }}>
<Button />
<UnrelatedChart /> {/* re-renders on every App render */}
</ThemeContext.Provider>
);
}A new object reference on every render triggers all consumers. Wrap with useMemo: const value = useMemo(() => ({ theme, setTheme }), [theme]). In my experience, this is the one that shows up most in code reviews: someone replaced drilling with Context and wondered why performance got worse.
Real-world usage
- Chakra UI:
<ChakraProvider theme={customTheme}>injects the theme globally so no component needs theme props drilled - Material-UI:
<ThemeProvider>for styling across modals and drawers - Next.js app router: auth state lives in a Context Provider wrapping layout components
- Large production apps: Redux for business state, Context for UI-layer theming and locale
Follow-up questions
Q: Show prop drilling in an App -> Layout -> Sidebar -> UserMenu tree, then fix it with Context.
A: Pass user down through each layer as a prop. Then create UserContext, wrap Layout in UserContext.Provider, and call useContext(UserContext) directly in UserMenu. Layout and Sidebar become clean.
Q: When does Context cause more re-renders than prop drilling?
A: When the Provider value is a new object or array each render without useMemo. All consumers reconcile even if the data didn't logically change. Drilling only re-renders direct children.
Q: Context vs Redux for a shopping cart?
A: Context works for a simple cart total (local UI state). Redux is worth it once you need persistence, server sync, or complex actions like applying discount codes with async validation.
Q: How do you test a component that depends on Context without drilling?
A: Wrap the component in the Provider inside the test: render(<CartContext.Provider value={mockCart}><CartWidget /></CartContext.Provider>).
Q: In React 18+, how does concurrent mode affect drilled props vs Context?
A: Drilling can block suspense boundaries because the data isn't available until the parent renders. Context with useTransition lets React mark updates as non-urgent, so the UI stays responsive during slow renders.
Examples
Basic: user greeting without and with Context
// Problem: user drilled through Layout that doesn't need it
function App() {
const user = { name: 'Alice', role: 'admin' };
return <Layout user={user} />;
}
function Layout({ user }) { // doesn't use user
return <Navbar user={user} />;
}
function Navbar({ user }) {
return <span>Welcome, {user.name}</span>;
}
// Fix: Context
const UserContext = createContext(null);
function App() {
const user = { name: 'Alice', role: 'admin' };
return (
<UserContext.Provider value={user}>
<Layout />
</UserContext.Provider>
);
}
function Layout() {
return <Navbar />; // no user prop
}
function Navbar() {
const user = useContext(UserContext);
return <span>Welcome, {user.name}</span>;
}Layout is now completely decoupled from user data. Adding role or email to the user object doesn't require touching Layout at all.
Intermediate: e-commerce dashboard with cart Context
const CartContext = createContext();
function Dashboard() {
const [cartTotal, setCartTotal] = useState(3);
return (
<CartContext.Provider value={{ cartTotal, setCartTotal }}>
<Header />
</CartContext.Provider>
);
}
function Header() {
return (
<nav>
<Logo />
<Sidebar />
</nav>
);
}
function Sidebar() {
return <CartWidget />;
}
function CartWidget() {
const { cartTotal } = useContext(CartContext);
return <button>Cart ({cartTotal})</button>;
}Adding another cart consumer (like a checkout summary) means just calling useContext(CartContext) in that component. No prop changes needed anywhere in the tree.
Advanced: re-render trap and the fix
// Triggers re-renders in ALL consumers on every App render
const ThemeContext = createContext();
function App() {
const [theme, setTheme] = useState('light');
return (
<ThemeContext.Provider value={{ theme, setTheme }}>
{/* new object every render */}
<Button />
<ExpensiveChart /> {/* re-renders even on unrelated state changes */}
</ThemeContext.Provider>
);
}
// Fix: memoize the value
function App() {
const [theme, setTheme] = useState('light');
const value = useMemo(() => ({ theme, setTheme }), [theme]);
return (
<ThemeContext.Provider value={value}>
<Button />
<ExpensiveChart /> {/* now only re-renders when theme changes */}
</ThemeContext.Provider>
);
}setTheme is stable (same reference from useState), so useMemo with [theme] only produces a new object when theme actually changes. ExpensiveChart stops updating on unrelated renders.
Short Answer
Interview readyA concise answer to help you respond confidently on this topic during an interview.