What prop drilling is and why it hurts
Prop drilling means passing data through multiple intermediate components that don't use it themselves — they just forward it so a deeply nested child can access it.
// `user` is needed only by Button, but it travels through every layer
function App() { return <Page user={user} /> }
function Page({ user }) { return <Section user={user} /> }
function Section({ user }) { return <Widget user={user} /> }
function Widget({ user }) { return <Button user={user} /> }
function Button({ user }) { return <span>{user.name}</span> }
Page, Section, and Widget all carry a user prop they never read.
This creates several problems:
- Coupling — every intermediate layer must be updated if the shape of
userchanges. - Refactor friction — adding or renaming a prop means touching every layer in the chain.
- Bloated signatures — intermediate components accumulate pass-through props that obscure their real interface.
- Testing burden — tests for
PageandSectionmust supplyusereven though those tests don't care about it.
The good news: prop drilling has multiple solutions, ordered from simplest to most powerful.
Solution 1: Co-locate state closer to the consumer
Before reaching for any pattern, ask: does this state really need to live so high up? Moving state down to the component that needs it eliminates the intermediate passes entirely.
// ❌ Modal state in App drills through three layers
function App() {
const [isOpen, setIsOpen] = useState(false)
return <Header isOpen={isOpen} onToggle={setIsOpen} />
}
// ✅ Modal state lives right next to the button that opens it
function Header() {
const [isOpen, setIsOpen] = useState(false)
return (
<>
<button onClick={() => setIsOpen(true)}>Menu</button>
{isOpen && <MobileMenu onClose={() => setIsOpen(false)} />}
</>
)
}
This is the inverse of "lift state up" — only lift when siblings need to share state; keep it local otherwise.
Solution 2: Component composition with the children prop
React's children prop lets you pass pre-built JSX into a component. The
parent builds the element with full access to its own data; the intermediary
just renders it.
// The classic drilling problem:
// App has `user`, Button needs `user`, Layout/Section don't care
// ❌ Drilling
function App() { return <Layout user={user} /> }
function Layout({ user }) { return <Section user={user} /> }
function Section({ user }) { return <Button user={user} /> }
// ✅ Composition — App builds Button directly
function App() {
return (
<Layout>
<Section>
<Button user={user} /> {/* App owns the user reference */}
</Section>
</Layout>
)
}
function Layout({ children }) { return <main>{children}</main> }
function Section({ children }) { return <div>{children}</div> }
// Neither component knows about user
React calls this inversion of control: the top-level owner decides what gets rendered, and the structural components just provide layout.
Solution 3: Named slots for multiple content areas
When a component has distinct content regions (header, sidebar, body), use named props — each receiving a JSX element — instead of threading data through to sub-components.
function Layout({ header, sidebar, children }) {
return (
<div className="layout">
<header className="top-bar">{header}</header>
<aside className="side-bar">{sidebar}</aside>
<main className="content">{children}</main>
</div>
)
}
// The caller builds each slot with its own data — Layout knows nothing
<Layout
header={<NavBar user={user} />}
sidebar={<UserMenu user={user} />}
>
<Feed posts={posts} />
</Layout>
Layout is a pure structural component. It gets no data props at all. The
caller controls all content by composing JSX into the slots.
Solution 4: Context API for cross-cutting concerns
Composition works well when you control the tree from the top. It gets awkward when:
- The consumer is many levels deep inside a third-party library
- The data is needed by many unrelated subtrees (authentication, theme, locale)
- Composing JSX from the top would make the call site unreadable
In those cases, Context is the right tool:
const UserContext = createContext(null)
function App() {
return (
<UserContext.Provider value={user}>
{/* No user prop anywhere — consumers read it directly */}
<Header />
<Main />
<Footer />
</UserContext.Provider>
)
}
function Button() {
const user = useContext(UserContext)
return <span>{user.name}</span>
}
Context is best for infrequently changing global values. For data that changes often (like a live count), all consumers re-render on every change — which can be expensive.
Solution 5: Render props for injectable logic
A render prop is a prop whose value is a function that returns JSX. The component calls the function, passing internal state to the caller:
function MouseTracker({ render }) {
const [pos, setPos] = useState({ x: 0, y: 0 })
return (
<div onMouseMove={e => setPos({ x: e.clientX, y: e.clientY })}>
{render(pos)}
</div>
)
}
// The consumer decides what to render with the tracked position
<MouseTracker render={({ x, y }) => <Crosshair x={x} y={y} />} />
Render props share logic without a prop-drilling chain, but they nest deeply in complex cases. Custom hooks have mostly replaced render props for logic sharing in modern React.
Solution 6: Compound components for component families
The compound component pattern combines Context + composition to create a
group of components that work together — like <Select> and <Option>, or
<Accordion> and <Accordion.Item>.
const AccordionCtx = createContext(null)
function Accordion({ children }) {
const [openId, setOpenId] = useState(null)
return (
<AccordionCtx.Provider value={{ openId, setOpenId }}>
<div>{children}</div>
</AccordionCtx.Provider>
)
}
Accordion.Item = function Item({ id, title, children }) {
const { openId, setOpenId } = useContext(AccordionCtx)
const isOpen = openId === id
return (
<div>
<button onClick={() => setOpenId(isOpen ? null : id)}>{title}</button>
{isOpen && <div>{children}</div>}
</div>
)
}
// Usage — no explicit state passing at all
<Accordion>
<Accordion.Item id="faq-1" title="What is React?">
A UI library for building component trees.
</Accordion.Item>
<Accordion.Item id="faq-2" title="What is JSX?">
Syntactic sugar for React.createElement.
</Accordion.Item>
</Accordion>
The caller gets a clean, declarative JSX API. State is shared via the
implicit Context created inside Accordion.
Choosing the right solution
| Situation | Best approach |
|---|---|
| State used only in a subtree | Co-locate — move state down |
| Data needed by a deeply nested child you build | Composition / children |
| Multiple distinct content areas in a layout | Named slots (props accepting JSX) |
| Data needed across many unrelated subtrees | Context API |
| Logic to share without a hierarchy | Custom hook (or render prop) |
| Group of related UI components | Compound components |
| Frequently updating shared state, many consumers | Zustand / Redux |
Start at the top of this list. Only move down when the simpler option doesn't fit.
TypeScript tip: spread rest props to avoid prop drilling on wrappers
When writing a component that wraps a native element, extend its HTML
attributes and spread ...rest. This forwards every native prop without
listing each one:
import { ButtonHTMLAttributes } from 'react'
interface IconButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
icon: string
}
function IconButton({ icon, ...rest }: IconButtonProps) {
return (
<button {...rest}>
<i className={`icon-${icon}`} />
{rest.children}
</button>
)
}
// Passes onClick, disabled, aria-label, type, etc. without drilling each
<IconButton icon="save" onClick={save} disabled={!dirty} aria-label="Save" />
Key interview points
- Prop drilling is not inherently wrong — passing a prop one level down is fine. The problem starts at two or three layers of pass-through.
- The first fix to try is co-location — move state closer to the consumer.
- Composition (
children, named slots) eliminates drilling by letting the owner build the element directly. - Context is for cross-cutting, infrequently changing data — not a replacement for any prop passing.
- Compound components share implicit state between a parent and its child sub-components via Context, giving callers a clean JSX API.
- Don't jump straight to Redux — work through this list in order.