Episode 2 — React Frontend Architecture NextJS / 2.6 — Component Architecture Principles
2.6.d — Prop Drilling Problem
In one sentence: Prop drilling happens when you pass data through multiple intermediate components that don't use it themselves — creating fragile, hard-to-maintain chains that break when the tree changes.
Navigation: ← Lifting State Up · Next → Component Composition
Table of Contents
- What Is Prop Drilling?
- Why It Becomes a Problem
- Real-World Example: User Authentication
- Measuring the Pain
- Solution 1: Component Composition (children)
- Solution 2: Context API
- Solution 3: Zustand (Lightweight Store)
- Solution 4: URL State
- Solution 5: Component Restructuring
- Comparison of Solutions
- When Prop Drilling Is Actually Fine
- Anti-Patterns to Avoid
- Decision Framework
- Key Takeaways
1. What Is Prop Drilling?
Prop drilling (also called "threading" or "plumbing") occurs when you pass props through components that don't need them — only to deliver data to deeply nested children.
┌───────────────────────────────────────────┐
│ App │
│ user ─────────┐ │
│ │ │
│ ┌──────────────────────┐ │ user (prop) │
│ │ Layout │◄┘ │
│ │ (doesn't use user) │ │
│ │ │ │
│ │ ┌──────────────────┐ │ user (prop) │
│ │ │ Header │◄┘ │
│ │ │ (doesn't use user)│ │
│ │ │ │ │
│ │ │ ┌──────────────┐ │ user (prop) │
│ │ │ │ NavBar │◄┘ │
│ │ │ │(doesn't use) │ │
│ │ │ │ │ │
│ │ │ │ ┌──────────┐ │ user (prop) │
│ │ │ │ │ UserMenu │◄┘ │
│ │ │ │ │ │ FINALLY USES IT │
│ │ │ │ └──────────┘ │
│ │ │ └──────────────┘ │
│ │ └──────────────────┘ │
│ └──────────────────────┘ │
└───────────────────────────────────────────┘
4 levels deep! Layout, Header, NavBar
don't use "user" — they just pass it through.
The Code
// ❌ Classic prop drilling
function App() {
const [user, setUser] = useState({ name: 'Alice', role: 'admin' });
return <Layout user={user} onLogout={() => setUser(null)} />;
}
function Layout({ user, onLogout }) {
// Layout doesn't care about user — just passing through
return (
<div className="layout">
<Header user={user} onLogout={onLogout} />
<main>
<Outlet />
</main>
</div>
);
}
function Header({ user, onLogout }) {
// Header doesn't care about user either
return (
<header>
<Logo />
<NavBar user={user} onLogout={onLogout} />
</header>
);
}
function NavBar({ user, onLogout }) {
// Still just passing through
return (
<nav>
<a href="/">Home</a>
<a href="/products">Products</a>
<UserMenu user={user} onLogout={onLogout} />
</nav>
);
}
function UserMenu({ user, onLogout }) {
// FINALLY uses the data!
return (
<div className="user-menu">
<span>{user.name}</span>
<button onClick={onLogout}>Logout</button>
</div>
);
}
2. Why It Becomes a Problem
Problem 1: Maintenance Nightmare
When you add a new prop (e.g., user.avatar), you must update EVERY component in the chain:
// Must update ALL of these just because UserMenu needs avatar:
<Layout user={user} onLogout={onLogout} /> // +avatar here
<Header user={user} onLogout={onLogout} /> // +avatar here
<NavBar user={user} onLogout={onLogout} /> // +avatar here
<UserMenu user={user} onLogout={onLogout} /> // already here
Problem 2: Tight Coupling
Intermediate components know about data they don't use, creating unnecessary dependencies:
// Layout's props include user concerns it doesn't care about
// If user changes shape (e.g., name → firstName + lastName),
// Layout's interface must change even though Layout doesn't render user data
function Layout({ user, onLogout, onUpdateProfile, userPreferences }) {
// Uses NONE of these ↑
return <Header user={user} onLogout={onLogout}
onUpdateProfile={onUpdateProfile} userPreferences={userPreferences} />;
}
Problem 3: Refactoring Risk
Moving a component in the tree breaks the prop chain:
BEFORE: App → Layout → Header → NavBar → UserMenu
AFTER: App → Layout → Sidebar → UserMenu (moved UserMenu to sidebar)
Now you need to:
1. Remove user/onLogout from Header and NavBar
2. Add user/onLogout to Sidebar
3. Hope you don't break anything
Problem 4: Performance
Every intermediate component re-renders when drilled props change, even though they don't use the props:
// Layout re-renders when user changes, even though it just passes user through
// This triggers re-render of ALL Layout's children
function Layout({ user, onLogout }) {
console.log('Layout re-rendered!'); // Every time user changes
return (...);
}
Problem 5: Readability
Component signatures become bloated with pass-through props:
// ❌ What does PageWrapper actually DO vs just pass through?
function PageWrapper({
user, onLogout, theme, onThemeChange, notifications,
onMarkRead, cart, onCartUpdate, locale, onLocaleChange
}) {
return (...);
}
// Answer: it uses theme and locale. The other 8 props are drilled.
3. Real-World Example: User Authentication
A realistic authentication example showing prop drilling pain:
// ❌ Authentication data drilled through entire app
function App() {
const [user, setUser] = useState(null);
const [isLoading, setIsLoading] = useState(true);
useEffect(() => {
checkAuth().then(setUser).finally(() => setIsLoading(false));
}, []);
const login = async (credentials) => {
const user = await api.login(credentials);
setUser(user);
};
const logout = async () => {
await api.logout();
setUser(null);
};
if (isLoading) return <LoadingScreen />;
return (
<Router>
<AppLayout
user={user}
onLogin={login}
onLogout={logout}
>
<Routes>
<Route path="/" element={
<HomePage user={user} />
} />
<Route path="/profile" element={
<ProfilePage user={user} onLogout={logout} />
} />
<Route path="/settings" element={
<SettingsPage user={user} onLogout={logout} />
} />
</Routes>
</AppLayout>
</Router>
);
}
// Every page component receives user as prop
// Every layout component passes it through
// Every nested component in each page that needs user
// has to receive it through MORE drilling
4. Measuring the Pain
The Prop Drilling Score
Count how many components a prop passes through without being used:
| Score | Assessment | Action |
|---|---|---|
| 0 (direct parent→child) | Not drilling | No action needed |
| 1 (one passthrough) | Mild | Usually fine |
| 2 (two passthroughs) | Moderate | Consider alternatives |
| 3+ (three or more) | Severe | Definitely refactor |
Counting in Our Example
user prop path: App → Layout → Header → NavBar → UserMenu
↑ ↑ ↑
pass pass pass USE
Passthrough count: 3 (Layout, Header, NavBar don't use it)
Score: 3 → Severe → Refactor!
Props That Are Most Commonly Drilled
| Prop | Why it's drilled | Better solution |
|---|---|---|
user / auth state | Needed at leaf components (UserMenu, Avatar) | Context or Zustand |
theme | Used by many scattered components | Context |
locale / i18n | Used by every text-rendering component | Context |
onNavigate | Deep components need to trigger navigation | useNavigate hook |
permissions | Authorization checks at various levels | Context |
5. Solution 1: Component Composition (children)
The simplest solution — restructure the component tree so data doesn't need to pass through intermediaries.
Before: Drilling
function App() {
const [user, setUser] = useState(currentUser);
return (
<Layout user={user}>
<Header user={user}>
<NavBar user={user}>
<UserMenu user={user} />
</NavBar>
</Header>
</Layout>
);
}
After: Composition
function App() {
const [user, setUser] = useState(currentUser);
return (
<Layout
header={
<Header
nav={
<NavBar>
<UserMenu user={user} /> {/* user passed directly — 0 drilling */}
</NavBar>
}
/>
}
>
<main>Content</main>
</Layout>
);
}
// Layout doesn't receive user at all
function Layout({ header, children }) {
return (
<div className="layout">
{header}
{children}
</div>
);
}
// Header doesn't receive user at all
function Header({ nav }) {
return (
<header>
<Logo />
{nav}
</header>
);
}
// NavBar doesn't receive user at all
function NavBar({ children }) {
return (
<nav>
<a href="/">Home</a>
<a href="/products">Products</a>
{children}
</nav>
);
}
How It Works
BEFORE (prop drilling):
App passes user → Layout → Header → NavBar → UserMenu
AFTER (composition):
App creates <UserMenu user={user} /> directly
App passes the ELEMENT (not the data) down via children/slots
Intermediaries don't know about user at all
Limitations
- Works best for "slot" patterns (header, sidebar, content)
- Becomes awkward when the drilled prop is used at multiple leaf components scattered across the tree
- Doesn't help when deeply nested children need to trigger parent state updates
6. Solution 2: Context API
React's built-in solution for sharing data across many components without explicit prop passing.
Basic Context Pattern
import { createContext, useContext, useState, useEffect } from 'react';
// 1. Create the context
const AuthContext = createContext(null);
// 2. Create a provider component
function AuthProvider({ children }) {
const [user, setUser] = useState(null);
const [isLoading, setIsLoading] = useState(true);
useEffect(() => {
checkAuth()
.then(setUser)
.finally(() => setIsLoading(false));
}, []);
const login = async (credentials) => {
const userData = await api.login(credentials);
setUser(userData);
};
const logout = async () => {
await api.logout();
setUser(null);
};
const value = { user, isLoading, login, logout };
return (
<AuthContext.Provider value={value}>
{children}
</AuthContext.Provider>
);
}
// 3. Create a custom hook for consuming
function useAuth() {
const context = useContext(AuthContext);
if (context === null) {
throw new Error('useAuth must be used within an AuthProvider');
}
return context;
}
// 4. Wrap your app
function App() {
return (
<AuthProvider>
<Layout>
<Header />
<main><Routes>...</Routes></main>
</Layout>
</AuthProvider>
);
}
// 5. Use ANYWHERE — no drilling!
function UserMenu() {
const { user, logout } = useAuth();
return (
<div>
<span>{user.name}</span>
<button onClick={logout}>Logout</button>
</div>
);
}
// Layout doesn't know about user ✅
function Layout({ children }) {
return <div className="layout">{children}</div>;
}
// Header doesn't know about user ✅
function Header() {
return (
<header>
<Logo />
<NavBar />
</header>
);
}
// NavBar doesn't know about user ✅
function NavBar() {
return (
<nav>
<a href="/">Home</a>
<UserMenu />
</nav>
);
}
Before vs After
BEFORE (drilling): AFTER (context):
App ──user──► Layout App
──user──► Header └─ AuthProvider
──user──► NavBar └─ Layout (no user prop)
──user──► UserMenu └─ Header (no user prop)
└─ NavBar (no user prop)
└─ UserMenu
└─ useAuth() ← reads directly
When Context Is the Right Choice
| Use Case | Why Context Works |
|---|---|
| Auth/user data | Used by many scattered components |
| Theme (dark/light) | Every styled component might need it |
| Locale/i18n | Every text component needs it |
| Feature flags | Checked throughout the tree |
| Toast/notification system | Triggered from anywhere |
Context Gotchas
// ⚠️ Context re-renders ALL consumers when value changes
// If you put everything in one context, typing in a search box
// will re-render components that only use the theme
// ❌ One mega-context
const AppContext = createContext({
user: null,
theme: 'light',
locale: 'en',
notifications: [],
cart: [],
});
// ✅ Separate contexts by rate of change
const AuthContext = createContext(null); // Changes rarely (login/logout)
const ThemeContext = createContext('light'); // Changes rarely (toggle)
const CartContext = createContext(null); // Changes often (add/remove items)
7. Solution 3: Zustand (Lightweight Store)
When Context becomes unwieldy or you need more performance control, Zustand offers a minimal alternative.
import { create } from 'zustand';
// Create store — no Provider needed!
const useAuthStore = create((set) => ({
user: null,
isLoading: true,
login: async (credentials) => {
const user = await api.login(credentials);
set({ user });
},
logout: async () => {
await api.logout();
set({ user: null });
},
checkAuth: async () => {
try {
const user = await api.getCurrentUser();
set({ user, isLoading: false });
} catch {
set({ user: null, isLoading: false });
}
},
}));
// Use ANYWHERE — even simpler than Context
function UserMenu() {
const user = useAuthStore(state => state.user);
const logout = useAuthStore(state => state.logout);
return (
<div>
<span>{user.name}</span>
<button onClick={logout}>Logout</button>
</div>
);
}
// Selective subscription — only re-renders when user changes
function Avatar() {
const avatar = useAuthStore(state => state.user?.avatar);
return <img src={avatar} alt="" />;
// Does NOT re-render when other store properties change
}
Zustand vs Context
| Aspect | Context | Zustand |
|---|---|---|
| Setup | Provider wrapper + createContext + useContext | Single create() call |
| Re-renders | All consumers on any value change | Only consumers of changed data |
| Provider nesting | Can become "Provider hell" | No providers needed |
| DevTools | React DevTools | Zustand devtools middleware |
| Bundle size | 0 (built-in) | ~1KB |
| Learning curve | Low | Low |
8. Solution 4: URL State
For state that should be shareable via URL (search params, filters, pagination), use URL state instead of React state.
import { useSearchParams } from 'react-router-dom';
// No prop drilling, no context — state lives in the URL
function SearchPage() {
return (
<div>
<SearchBar /> {/* reads/writes URL params */}
<FilterSidebar /> {/* reads/writes URL params */}
<SearchResults /> {/* reads URL params */}
</div>
);
}
function SearchBar() {
const [searchParams, setSearchParams] = useSearchParams();
const query = searchParams.get('q') || '';
return (
<input
value={query}
onChange={e => {
searchParams.set('q', e.target.value);
setSearchParams(searchParams);
}}
/>
);
}
function FilterSidebar() {
const [searchParams, setSearchParams] = useSearchParams();
const category = searchParams.get('category') || 'all';
return (
<select
value={category}
onChange={e => {
searchParams.set('category', e.target.value);
setSearchParams(searchParams);
}}
>
<option value="all">All</option>
<option value="electronics">Electronics</option>
</select>
);
}
function SearchResults() {
const [searchParams] = useSearchParams();
const query = searchParams.get('q');
const category = searchParams.get('category');
const { results } = useSearch({ query, category });
return <ProductGrid products={results} />;
}
Benefits: State survives page refresh, is shareable via URL, works with browser back/forward.
9. Solution 5: Component Restructuring
Sometimes the solution isn't adding a tool — it's rearranging the component tree.
Before: Deep Drilling Due to Poor Structure
function App() {
const [selectedId, setSelectedId] = useState(null);
return (
<Dashboard selectedId={selectedId} onSelect={setSelectedId}>
<Sidebar selectedId={selectedId} onSelect={setSelectedId}>
<ItemList selectedId={selectedId} onSelect={setSelectedId}>
<ItemRow selectedId={selectedId} onSelect={setSelectedId} />
</ItemList>
</Sidebar>
<MainPanel selectedId={selectedId} />
</Dashboard>
);
}
After: Flatter Structure Eliminates Drilling
function App() {
const [selectedId, setSelectedId] = useState(null);
return (
<div className="dashboard">
<Sidebar>
<ItemList
selectedId={selectedId}
onSelect={setSelectedId} {/* Only 1 level deep */}
/>
</Sidebar>
<MainPanel selectedId={selectedId} /> {/* Only 1 level deep */}
</div>
);
}
// Sidebar is just a layout wrapper — no props about selection
function Sidebar({ children }) {
return <aside className="sidebar">{children}</aside>;
}
10. Comparison of Solutions
┌─────────────────────────────────────────────────────────────────┐
│ PROP DRILLING SOLUTIONS │
├──────────────────┬──────────┬─────────────┬────────────────────┤
│ Solution │Complexity│ Best For │ Watch Out │
├──────────────────┼──────────┼─────────────┼────────────────────┤
│ Composition │ Low │ Layout slots│ Gets awkward with │
│ (children/slots) │ │ 1-2 levels │ many injections │
├──────────────────┼──────────┼─────────────┼────────────────────┤
│ Context API │ Medium │ App-wide │ Re-render ALL │
│ │ │ infrequent │ consumers on change│
│ │ │ changes │ │
├──────────────────┼──────────┼─────────────┼────────────────────┤
│ Zustand │ Medium │ Frequent │ External dependency│
│ │ │ updates, │ (1KB) │
│ │ │ performance │ │
├──────────────────┼──────────┼─────────────┼────────────────────┤
│ URL State │ Low │ Shareable │ Only for │
│ │ │ state │ serializable data │
├──────────────────┼──────────┼─────────────┼────────────────────┤
│ Restructure │ Low │ Poor tree │ Not always possible│
│ │ │ design │ │
└──────────────────┴──────────┴─────────────┴────────────────────┘
11. When Prop Drilling Is Actually Fine
Not all prop drilling is bad. These cases are fine:
Fine: One Level of Passthrough
// Only one passthrough — not worth adding Context
function Page({ user }) {
return <Header user={user} />; // Header directly uses user
}
Fine: Explicit Data Flow Is Clear
// The prop chain makes data flow visible
function Form({ onSubmit }) {
return <SubmitButton onSubmit={onSubmit} />;
}
// You can trace where onSubmit comes from by reading the code
Fine: Simple Component Trees
// Small tree — prop drilling is the simplest solution
function Modal({ title, children, onClose }) {
return (
<div className="modal">
<ModalHeader title={title} onClose={onClose} />
<div className="modal-body">{children}</div>
</div>
);
}
The "Context Is Not Always Better" Reminder
Context adds:
- Indirection (harder to trace data flow)
- Provider nesting (wrapper hell)
- Re-render concerns (all consumers re-render)
Prop drilling provides:
- Explicit, traceable data flow
- Easy refactoring (just follow the props)
- No hidden dependencies
12. Anti-Patterns to Avoid
Anti-Pattern 1: Context for Everything
// ❌ Creating context for data used by 2 adjacent components
const SearchContext = createContext();
function SearchProvider({ children }) {
const [query, setQuery] = useState('');
return (
<SearchContext.Provider value={{ query, setQuery }}>
{children}
</SearchContext.Provider>
);
}
// Overkill! Just lift state to the parent:
function SearchPage() {
const [query, setQuery] = useState('');
return (
<>
<SearchBar query={query} onChange={setQuery} />
<Results query={query} />
</>
);
}
Anti-Pattern 2: Spreading All Props
// ❌ "Solving" drilling by spreading — hides what's actually passed
function Layout(props) {
return <Header {...props} />;
}
function Header(props) {
return <NavBar {...props} />;
}
// Now you can't tell what props each component actually uses
// And you pass unnecessary props to DOM elements (React warnings)
Anti-Pattern 3: Using Context to Avoid Lifting State
// ❌ Two adjacent components that just need shared state
// Don't create a context — just lift state up!
function Parent() {
const [count, setCount] = useState(0);
return (
<>
<Incrementer onIncrement={() => setCount(c => c + 1)} />
<Display count={count} />
</>
);
}
// Simple, clear, no context needed.
13. Decision Framework
Components need shared data?
│
├─► Adjacent (parent-child or siblings)?
│ └─► YES → LIFT STATE UP (props)
│ └─► Too many props? → COMPOSITION (children/slots)
│
├─► 3+ levels apart?
│ ├─► Data changes rarely (auth, theme, locale)?
│ │ └─► CONTEXT API
│ ├─► Data changes often (cart, forms, real-time)?
│ │ └─► ZUSTAND or CONTEXT with split
│ └─► Data should be in URL (search, filters, pagination)?
│ └─► URL STATE (useSearchParams)
│
└─► Used across routes/pages?
├─► Server data? → TANSTACK QUERY (caches across routes)
└─► Client data? → ZUSTAND or CONTEXT
Quick Reference
| Drilling Depth | Solution |
|---|---|
| 0 (direct parent→child) | Props — always fine |
| 1 (one passthrough) | Props or composition — fine |
| 2 (two passthroughs) | Composition or consider Context |
| 3+ | Context, Zustand, or restructure |
14. Key Takeaways
-
Prop drilling = passing props through components that don't use them, just to reach deeply nested consumers.
-
It's a problem when: More than 2 levels of passthrough, the prop chain breaks on refactors, intermediate components have bloated signatures.
-
Five solutions: Composition (children/slots), Context API, Zustand, URL state, component restructuring.
-
Composition first: Often the simplest fix — pass elements as children instead of data as props.
-
Context for global, infrequent-change data: Auth, theme, locale. Split contexts by rate of change.
-
Zustand for performance-sensitive cases: When Context re-renders too much, Zustand provides selective subscriptions.
-
Don't over-solve: 1-2 levels of prop passing is fine. Adding Context for adjacent components is over-engineering.
-
Explicit is not always bad: Props make data flow visible. Context/stores add indirection. Choose the right trade-off.
Explain-It Challenge
-
Explain to a coworker: Using the analogy of passing a note in a classroom, explain why prop drilling becomes problematic and what "composition" is as a solution.
-
Choose the solution: You have a shopping cart that needs to display an item count in the header and full cart details in a cart page (different routes). Both are 5+ components deep from the root. What solution do you recommend and why?
-
Refactor this: You have
App → Dashboard → Sidebar → Navigation → UserBadgewhere onlyUserBadgeneedsuser. Show two different approaches to eliminate the drilling.
Navigation: ← Lifting State Up · Next → Component Composition