3 min read
Composition Over Prop Drilling
Prop drilling is what happens when a prop has to travel through components that don't use it, just to reach the one that does. It's the kind of thing that feels fine with two layers, annoying with three, and broken with four. The usual fix people reach for is Context. The better fix, most of the time, is composition.
The Problem
Here's the shape of the issue. A page fetches a user and needs to show it in a header several layers down:
function Page() {
const user = useUser()
return <Layout user={user} />
}
function Layout({ user }: { user: User }) {
return <Shell user={user} />
}
function Shell({ user }: { user: User }) {
return <Header user={user} />
}
function Header({ user }: { user: User }) {
return <p>Hello, {user.name}</p>
}
Layout and Shell don't care about user. They're just passing it along. That's the smell.
Children as a Prop
The fix is to stop making the intermediate components know about the data at all. Instead of passing user down, pass the finished Header down as children:
function Page() {
const user = useUser()
return (
<Layout>
<Shell>
<Header user={user} />
</Shell>
</Layout>
)
}
function Layout({ children }: { children: React.ReactNode }) {
return <div className="layout">{children}</div>
}
function Shell({ children }: { children: React.ReactNode }) {
return <div className="shell">{children}</div>
}
Layout and Shell are now generic containers. They don't know about user, and they don't need to. Page owns the data, so it's the component that does the wiring.
Slots for More Than One Child
children gets you one slot. When you need more than one, slots become named props:
type PageShellProps = {
header: React.ReactNode
sidebar: React.ReactNode
main: React.ReactNode
}
function PageShell({ header, sidebar, main }: PageShellProps) {
return (
<div className="grid">
<header>{header}</header>
<aside>{sidebar}</aside>
<main>{main}</main>
</div>
)
}
Now the caller decides what goes in each slot. PageShell doesn't care whether the header is a search bar, a user menu, or both.
<PageShell
header={<Header user={user} />}
sidebar={<Nav items={links} />}
main={<ProductList products={products} />}
/>
Each slot is independently typed. The parent still owns all the data. Nothing is drilled.
When Context Actually Helps
Composition doesn't kill Context. It changes when you reach for it. Use Context when the data is genuinely global and a lot of leaves need it: a theme, a current user for authorisation checks, a locale. Don't use it when two or three components need the same prop. You're trading a small amount of typing for a lot of implicit coupling, and implicit coupling is what makes large apps hard to change.
A good test: if you're adding a Context because you're tired of typing user={user}, you probably want composition. If you're adding a Context because fifteen components scattered across the tree need the current user and you don't want them to know who fetched it, that's the right tool.
What You Get
Composition has some practical upsides that are easy to miss until you've lived with the alternative:
- Fewer re-renders. If
Layoutdoesn't takeuser, it doesn't re-render whenuserchanges. Only theHeaderdoes. - Easier testing.
LayoutandShelltakeReactNode. You can mount them with any child, including a stub, without faking a provider tree. - Reusable primitives. Once
PageShellis generic, it works for every page. You don't end up with aProductPageShelland aCartPageShellthat are 90% the same code.
The rule I use: props that describe what a component does are fine to pass. Props that describe what its children do probably shouldn't exist. If you find yourself passing a prop that you're not going to use at this level, pass a child instead.