open atlas
↑ Back to track
React patterns, senior RXP · 01 · 03

Prop drilling, and when it's fine

Prop drilling is passing a prop through components that do not use it. Two or three levels is often fine — explicit and greppable; reach for passing children, then context, only once drilling actually hurts.

RXP Middle ◷ 18 min
Level
FoundationsJuniorMiddleSenior

You pass user into <Page>, which passes it to <Layout>, which passes it to <Sidebar>, which finally hands it to <Avatar>. Three components in the middle touched a prop they never read. That’s prop drilling, and the moment a junior sees it they reach for context — because the internet told them drilling is a code smell.

The internet is half right. Drilling can rot a codebase. But a senior reads the same three-level chain and very often shrugs: it’s explicit, it’s greppable, it has no magic, and it’s about to be refactored anyway. The skill isn’t eliminating prop drilling — it’s knowing the threshold past which it stops being fine, and reaching for the cheapest cure first.

Goal

After this lesson you can define prop drilling precisely (a prop passed through components that don’t consume it); judge when drilling is fine (shallow, stable, explicit) versus when it actually hurts (deep, churning, noise); eliminate drilling with composition — passing children so a level is skipped — before reaching for context; and name the failure mode of jumping to context too early: context-ifying state that only two nearby components share.

1

Prop drilling is passing a prop through intermediate components that don’t use it — only forward it. The cost isn’t the depth itself; it’s that every middle component now mentions a prop it has no business knowing about, so its signature is wider and a rename ripples through files that don’t care.

function Page({ user }: { user: User }) {
  return <Layout user={user} />;          // Layout doesn't read user
}
function Layout({ user }: { user: User }) {
  return <Sidebar user={user} />;         // Sidebar doesn't read user
}
function Sidebar({ user }: { user: User }) {
  return <Avatar user={user} />;          // finally, a consumer
}

Layout and Sidebar are pure pass-through. They gained a user prop in their type and their JSX purely as a relay. That relay is the smell — when there’s enough of it.

2

Two or three levels is often fine — don’t pay to remove it. Shallow, stable drilling is the most honest form of data flow React offers: you can trace exactly where the value comes from by reading the JSX, grep finds every consumer, and there is zero indirection or hidden subscription. A context, by contrast, hides the wiring and adds a re-render channel. For a prop that drills two levels and rarely changes shape, the relay is cheaper than any cure.

// This is fine. Leave it alone.
<Card>
  <CardHeader title={title} />     {/* one level */}
</Card>

function CardHeader({ title }: { title: string }) {
  return <h3>{title}</h3>;
}

Reaching for context here doesn’t make the code better — it trades a visible, typed prop for an invisible global. Senior instinct: shallow drilling is a non-problem until a threshold is crossed.

3

Drilling becomes a real smell past a threshold: many levels, many props, or churn. The pain compounds when (a) the chain is deep — five-plus relays; (b) you’re threading a bundle of props, not one; or (c) the shape changes often, so every edit re-touches every middle component. That’s when the pass-through tax shows up as merge conflicts and “why does Layout take twelve props it ignores?”.

// Now it hurts: a bundle of props, threaded deep, that the middles ignore.
<Layout user={user} theme={theme} locale={locale} onLogout={onLogout}>
  <Sidebar user={user} theme={theme} locale={locale} onLogout={onLogout}>
    <Nav user={user} theme={theme} locale={locale} onLogout={onLogout} />
  </Sidebar>
</Layout>

The trigger to act is concrete: count the relay-only levels and the bundled props. One or two of either is fine; four levels threading four ignored props is the signal to refactor.

4

Reach for the cheapest cure first: passing children to skip levels — then context. Most drilling disappears not with context but with composition: if a parent renders the deep child as children, the value flows from the owner straight to the consumer and the middle components never see it. They become layout shells that accept children and know nothing about user. Only when the consumers are genuinely distant and many and not co-renderable does context earn its cost.

// Composition cure: Layout/Sidebar take children, never see `user`.
function Page({ user }: { user: User }) {
  return (
    <Layout>
      <Sidebar>
        <Avatar user={user} />   {/* owner renders the consumer directly */}
      </Sidebar>
    </Layout>
  );
}
function Layout({ children }: { children: React.ReactNode }) {
  return <div className="layout">{children}</div>;
}

Layout and Sidebar lost their user prop entirely. No context, no library — just moving who renders what. This is the move to learn before context: composition removes the drilling without adding a subscription channel.

Worked example

A real chain, drilled, then cured with composition — not context. A dashboard threads the signed-in user and an onSignOut handler down to a header avatar four levels deep. Every middle component is pass-through.

Before — drilling a bundle through shells that ignore it:

function Dashboard({ user, onSignOut }: { user: User; onSignOut: () => void }) {
  return <Shell user={user} onSignOut={onSignOut} />;
}
function Shell({ user, onSignOut }: Props) {
  return <TopBar user={user} onSignOut={onSignOut} />;   // ignores both
}
function TopBar({ user, onSignOut }: Props) {
  return <UserMenu user={user} onSignOut={onSignOut} />; // ignores both
}
function UserMenu({ user, onSignOut }: Props) {          // the only consumer
  return <button onClick={onSignOut}>{user.name}</button>;
}

Shell and TopBar exist for layout; they take user and onSignOut only to relay them. Four signatures carry props two of them never read.

After — the owner renders the consumer and passes it as children:

function Dashboard({ user, onSignOut }: { user: User; onSignOut: () => void }) {
  return (
    <Shell>
      <TopBar>
        <UserMenu user={user} onSignOut={onSignOut} />  {/* rendered here */}
      </TopBar>
    </Shell>
  );
}
function Shell({ children }: { children: React.ReactNode }) {
  return <div className="shell">{children}</div>;       // no user prop
}
function TopBar({ children }: { children: React.ReactNode }) {
  return <header>{children}</header>;                    // no user prop
}

The drilling is gone. Shell and TopBar are now reusable layout shells with one job, and user/onSignOut travel directly from owner to consumer. Notice we did not create a UserContext — there’s exactly one consumer and it’s co-renderable from the owner, so context would be cost with no payoff. Composition was the right-sized cure; context stays in the toolbox for when consumers are many and distant.

Why this works

Why is passing children strictly cheaper than context for this? Because children adds no new dependency edge — the value still flows top-down through a single owner, fully visible and typed, and the middle components become genuinely ignorant of it (better, not just shorter). Context instead introduces a subscription: any component that calls useContext re-renders when the provider value changes, and the data source is now invisible at the call site. Both remove the relay, but only children removes it without trading explicitness for a hidden channel. So the order is deliberate: composition first, context only when distance and consumer-count make composition impractical.

Common mistake

The classic over-engineering failure: context-ifying state that only two nearby components share. A search box and a results list sit side by side under one parent; the box owns the query, the list reads it. A junior, allergic to the one prop between them, wraps the pair in a SearchContext. Now the shared state is global-shaped, every useContext consumer re-renders on each keystroke, the source is hidden, and you’ve added a provider for two components that could have lifted state into their common parent and passed one prop. Context is for widely shared, relatively stable state read by many distant components — not for two siblings. When only two nearby components share state, lift it to their parent and pass the prop. Reaching for context on the first pass is the same over-engineering this track keeps warning about.

Check yourself
Quiz

A signed-in user value is drilled exactly two levels (Page → Layout → Avatar) and rarely changes shape. A teammate wants to replace the drilling with a UserContext provider. What's the senior call?

Recap

Prop drilling is passing a prop through intermediate components that only relay it. It is not automatically a smell: two or three stable levels are often fine — explicit, typed, greppable, no magic — and removing them can cost more than it saves. Drilling turns into a real problem only past a threshold: deep chains, bundles of relayed props, or shapes that churn. When you do cure it, reach for the cheapest fix first — pass children so the owner renders the consumer directly and the middle components become ignorant layout shells, removing the relay without adding any subscription. Context is the last resort, justified only when consumers are many, distant, and not co-renderable. The signature failure mode is jumping straight to context — especially context-ifying state that only two nearby components share, which trades a single honest prop for a hidden global and needless re-renders. Match the cure to the pain: most drilling never needs context at all.

Practice

Start at the top. Tasks go easiest → hardest: recall a fact, apply it to a case, then a senior-level stretch. Open one, attempt it, then reveal.

recallapplystretch0 of 4 done

Something unclear?

Ask a question about this lesson. Questions are anonymous and go straight to the author to make the lesson better.

shortcuts expand
search
K
prev piece
k
next piece
j
cycle tier
t
this menu
?
sources3
expand
  1. 01
  2. 02
  3. 03

Trademarks belong to their respective owners. Editorial reference only.