Skip to main content

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

tsx
// 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

AspectProp DrillingReact ContextRedux/Zustand
Data flowManual at every levelProvider wraps subtreeGlobal store + selectors
Re-rendersOnly direct childrenAll consumers on value changeControlled via useSelector
BoilerplateNone initially, grows fastProvider + useContextActions/reducers (simpler in RTK)
TestingProp mocking straightforwardContext wrapper neededStore isolation
Best forFlat trees (<3 levels)UI state, medium appsLarge 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

tsx
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

tsx
// 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

tsx
<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

tsx
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

tsx
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

tsx
// 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

tsx
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

tsx
// 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 ready
Premium

A concise answer to help you respond confidently on this topic during an interview.

Finished reading?