Prop drilling is the practice of passing data through multiple intermediate components that don't need it themselves, just so it can reach a deeply nested consumer.
// data flows through App → Page → Section → Widget → Button
// Page, Section, and Widget never use `user`, they just pass it on
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> }
The intermediate components become tightly coupled to data they don't own, making refactoring painful.
Rule of thumb: If a prop passes through two or more components that don't use it, you have a prop drilling problem worth addressing.
- Coupling — intermediate components must accept and forward props they don't care about, creating unnecessary dependencies.
- Refactor friction — adding, removing, or renaming a prop means updating every intermediate layer.
- Readability — component signatures grow bloated with pass-through props, obscuring the component's real interface.
- Testing burden — intermediate components must be tested with props they don't use, just to satisfy type signatures.
// Every time the shape of `user` changes, all three layers must be
// updated — even Page and Section which never read user directly
function Page({ user, theme, locale, featureFlags }) { ... }
Rule of thumb: The deeper the tree and the more layers a prop crosses, the more painful prop drilling becomes.
Pass pre-rendered children (JSX elements) down instead of raw data. The top-level component builds the element with full access to its own scope; intermediate components just place it.
// Instead of threading `user` through Page → Section → Widget:
function App() {
return (
<Page>
<Section>
<Widget>
<Button user={user} /> {/* App has direct access to user */}
</Widget>
</Section>
</Page>
)
}
// Intermediates accept children and don't know about user
function Page({ children }) { return <main>{children}</main> }
function Section({ children }) { return <section>{children}</section> }
function Widget({ children }) { return <div>{children}</div> }
React calls this inversion of control — the parent controls what the children render.
Rule of thumb: Try the children pattern before reaching for
Context — it often eliminates drilling with zero new abstractions.
children is the implicit prop React passes for any JSX content placed
between a component's opening and closing tags. Components that render
{children} act as layout containers — they don't need to know
what's inside.
function Card({ title, children }) {
return (
<div className="card">
<h2>{title}</h2>
<div className="card-body">{children}</div>
</div>
)
}
// Usage
<Card title="Profile">
<Avatar user={user} />
<Bio text={user.bio} />
</Card>
Card renders the user-specific components without knowing anything
about user itself.
Rule of thumb: If a component is a structural wrapper (card, modal,
layout), always accept children rather than a specific content prop.
The slot pattern uses named props (each holding a JSX element) to place content in specific regions — like a header slot, footer slot, and body slot — without the component needing to know about the data inside.
function Layout({ header, sidebar, children }) {
return (
<div className="layout">
<header>{header}</header>
<aside>{sidebar}</aside>
<main>{children}</main>
</div>
)
}
// Caller builds each slot with its own data
<Layout
header={<NavBar user={user} />}
sidebar={<UserMenu user={user} />}
>
<Feed posts={posts} />
</Layout>
Layout remains generic; the caller composes what goes into each slot.
Rule of thumb: Use named slots when a layout component has multiple distinct areas that need different content.
Composition fails (or becomes awkward) when:
- The consuming component is many levels deep and you would need to thread JSX through too many layers to compose it at the top.
- The consuming component is in a third-party library or a route you don't control.
- The data is needed by many unrelated subtrees (e.g. current user, theme, locale).
// If Button is inside a deeply nested third-party table cell,
// you can't easily pass it as a child from the top.
// Context is the right tool here.
const UserContext = createContext(null)
function App() {
return (
<UserContext.Provider value={user}>
<ThirdPartyTable columns={columns} />
</UserContext.Provider>
)
}
function CustomCell() {
const user = useContext(UserContext)
return <Button>{user.name}</Button>
}
Rule of thumb: Try composition first. Reach for Context when the consumer and the data owner are architecturally disconnected.
A render prop is a prop whose value is a function that returns JSX. The component calls the function at render time, passing internal state or logic 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)} {/* caller decides what to show */}
</div>
)
}
// Usage — no prop drilling; the caller gets the data it needs
<MouseTracker render={({ x, y }) => <Cursor x={x} y={y} />} />
Render props share logic without a component hierarchy, but they can lead to deeply nested JSX ("callback hell"). Custom hooks have largely replaced them for logic sharing.
Rule of thumb: Prefer custom hooks for sharing stateful logic; use render props only when you need to inject JSX into a component that controls lifecycle.
An HOC is a function that takes a component and returns a new component with extra props injected. They were the pre-hooks solution for sharing logic without drilling, but they add indirection and can conflict with prop names.
function withUser(Component) {
return function WrappedComponent(props) {
const user = useContext(UserContext)
return <Component {...props} user={user} />
}
}
const ProfileWithUser = withUser(Profile)
// Profile receives user without the caller drilling it
HOCs are still used in third-party libraries (React-Redux's connect)
but custom hooks are preferred for new code.
Rule of thumb: Write a custom hook first. Only reach for an HOC when the consuming component is a class component or you need to inject into a component you don't own.
No. Passing props one level down is perfectly fine and is often the clearest approach. Prop drilling is only a problem when it spans many layers and the intermediate components have no other reason to know about the prop.
// Fine — one level, clear intent
function ProductPage({ product }) {
return <ProductCard product={product} />
}
// Problem — three layers, none of them use `user`
function ProductPage({ product, user }) {
return <ProductCard product={product} user={user} />
}
function ProductCard({ product, user }) {
return <SaveButton user={user} />
}
function SaveButton({ user }) {
return <button disabled={!user.isPremium}>Save</button>
}
Rule of thumb: One level is fine. Two levels — consider composition. Three or more — seriously evaluate Context or composition.
If state is lifted higher than it needs to be, every component between the owner and the consumer has to forward it. Moving state down to the component that actually needs it eliminates the intermediate passes.
// ❌ modal state lives in App, drills through Header → Nav → MenuButton
function App() {
const [isMenuOpen, setIsMenuOpen] = useState(false)
return <Header isMenuOpen={isMenuOpen} onToggle={setIsMenuOpen} />
}
// ✅ menu state lives where it belongs — no drilling
function Nav() {
const [isMenuOpen, setIsMenuOpen] = useState(false)
return <MenuButton open={isMenuOpen} onToggle={setIsMenuOpen} />
}
State co-location is the inverse of "lift state up" — lift only when two siblings need the same state; keep it local otherwise.
Rule of thumb: Before reaching for Context, ask "Can I just move this state down to where it's used?"
Zustand creates a store outside React's component tree. Any component can subscribe directly with a selector and only re-renders when its selected slice changes — no Provider needed, no intermediate forwarding.
import { create } from 'zustand'
const useUserStore = create(set => ({
user: null,
setUser: user => set({ user }),
}))
// Any component anywhere in the tree — no Provider, no drilling
function Button() {
const user = useUserStore(state => state.user)
return <span>{user?.name}</span>
}
Unlike Context, Zustand subscriptions are selector-scoped — a component only re-renders when its selected slice actually changes.
Rule of thumb: Add Zustand (or Redux Toolkit) when Context re-render performance becomes a real problem or when you need devtools / middleware.
- Co-location — move state down so it lives closer to the consumer.
- Composition / children — pass pre-rendered JSX or named slots so intermediate components don't need to know about the data.
- Context API — share data across a subtree without prop passing, best for infrequently changing global values.
- External store (Zustand, Redux) — selector-scoped subscriptions for frequently changing data with many consumers.
Co-location → simplest, no overhead
Composition → good DX, no new abstractions
Context → built-in, okay for infrequent updates
Store library → scalable, selector performance, devtools
Rule of thumb: Solve at the lowest complexity level that works. Don't jump to Redux because you read about prop drilling; try composition first.
Use rest props spread (...props) combined with a specific type
for your own props, so the component forwards everything else without
listing every forwarded prop explicitly.
import { ButtonHTMLAttributes } from 'react'
interface IconButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
icon: string // own prop
}
function IconButton({ icon, ...rest }: IconButtonProps) {
return (
<button {...rest}>
<i className={`icon-${icon}`} />
{rest.children}
</button>
)
}
// Caller can pass onClick, disabled, aria-label without drilling each one
<IconButton icon="save" onClick={save} disabled={!dirty} />
This pattern is especially useful for wrapper components around native elements.
Rule of thumb: Extend HTMLAttributes and spread ...rest for any
component that wraps a native element.
Compound components use implicit Context to share state between a parent and its child sub-components — the caller doesn't need to wire up any props between them.
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 prop drilling between Accordion and Item
<Accordion>
<Accordion.Item id="a" title="Section A">Content A</Accordion.Item>
<Accordion.Item id="b" title="Section B">Content B</Accordion.Item>
</Accordion>
Rule of thumb: Use the compound component pattern for component families (tabs, accordions, dropdowns) that need shared state but want a clean JSX API.
More State and Data Flow interview questions
More ways to practice
The self-quiz is live. Get notified when mock interviews and new question packs drop.