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

  1. What Is Prop Drilling?
  2. Why It Becomes a Problem
  3. Real-World Example: User Authentication
  4. Measuring the Pain
  5. Solution 1: Component Composition (children)
  6. Solution 2: Context API
  7. Solution 3: Zustand (Lightweight Store)
  8. Solution 4: URL State
  9. Solution 5: Component Restructuring
  10. Comparison of Solutions
  11. When Prop Drilling Is Actually Fine
  12. Anti-Patterns to Avoid
  13. Decision Framework
  14. 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:

ScoreAssessmentAction
0 (direct parent→child)Not drillingNo action needed
1 (one passthrough)MildUsually fine
2 (two passthroughs)ModerateConsider alternatives
3+ (three or more)SevereDefinitely 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

PropWhy it's drilledBetter solution
user / auth stateNeeded at leaf components (UserMenu, Avatar)Context or Zustand
themeUsed by many scattered componentsContext
locale / i18nUsed by every text-rendering componentContext
onNavigateDeep components need to trigger navigationuseNavigate hook
permissionsAuthorization checks at various levelsContext

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 CaseWhy Context Works
Auth/user dataUsed by many scattered components
Theme (dark/light)Every styled component might need it
Locale/i18nEvery text component needs it
Feature flagsChecked throughout the tree
Toast/notification systemTriggered 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

AspectContextZustand
SetupProvider wrapper + createContext + useContextSingle create() call
Re-rendersAll consumers on any value changeOnly consumers of changed data
Provider nestingCan become "Provider hell"No providers needed
DevToolsReact DevToolsZustand devtools middleware
Bundle size0 (built-in)~1KB
Learning curveLowLow

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

  1. Prop drilling = passing props through components that don't use them, just to reach deeply nested consumers.

  2. It's a problem when: More than 2 levels of passthrough, the prop chain breaks on refactors, intermediate components have bloated signatures.

  3. Five solutions: Composition (children/slots), Context API, Zustand, URL state, component restructuring.

  4. Composition first: Often the simplest fix — pass elements as children instead of data as props.

  5. Context for global, infrequent-change data: Auth, theme, locale. Split contexts by rate of change.

  6. Zustand for performance-sensitive cases: When Context re-renders too much, Zustand provides selective subscriptions.

  7. Don't over-solve: 1-2 levels of prop passing is fine. Adding Context for adjacent components is over-engineering.

  8. Explicit is not always bad: Props make data flow visible. Context/stores add indirection. Choose the right trade-off.


Explain-It Challenge

  1. 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.

  2. 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?

  3. Refactor this: You have App → Dashboard → Sidebar → Navigation → UserBadge where only UserBadge needs user. Show two different approaches to eliminate the drilling.


Navigation: ← Lifting State Up · Next → Component Composition