React

Composition Over Prop Drilling in React

How to use children as a prop, slot patterns, and component composition to avoid passing props through layers of components that don't care about them.

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

If you're new to how children works in React, props and component composition covers the fundamentals before you dive into prop drilling patterns.

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

If the issue is shared state between sibling components rather than prop drilling through intermediaries, lifting state up is worth considering before reaching for Context.

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. For a full walkthrough of how to wire up Context with TypeScript, including the provider pattern and custom hook, that's covered separately.

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.

When you need to take composition further — sharing state between a parent and several co-operating children — the compound component pattern builds on exactly these ideas.

← Older
Conditional Rendering Patterns in React
Newer →
Avoiding Unnecessary Rerenders in React

Newsletter

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