Skip to main content
paulund

3 min read

#react#patterns#composition

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 Layout doesn't take user, it doesn't re-render when user changes. Only the Header does.
  • Easier testing. Layout and Shell take ReactNode. You can mount them with any child, including a stub, without faking a provider tree.
  • Reusable primitives. Once PageShell is generic, it works for every page. You don't end up with a ProductPageShell and a CartPageShell that 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.

Related notes


Newsletter

A weekly newsletter on React, Next.js, AI-assisted development, and engineering. No spam, unsubscribe any time.