Skip to content

Component Composition

Component composition is a way to share state between components without adding intermediate props. Instead of passing data through every layer of the tree, you structure the components so the data consumer gets rendered directly by the data owner.

React’s children prop lets a component inject arbitrary JSX into its own render output. This is the simplest form of composition.

function Layout({ children }: { children: React.ReactNode }) {
return (
<div className="layout">
<header>My App</header>
<main>{children}</main>
<footer>Footer</footer>
</div>
)
}
// Usage
function Page() {
return (
<Layout>
<Dashboard />
</Layout>
)
}

Layout doesn’t know about Dashboard. It doesn’t need to. The state and props that Dashboard requires are handled by Page, the component that builds the composition. This avoids drilling props like dashboardData through Layout just so Layout can pass them down.

A render prop is a function prop that a component calls to decide what to render. The parent controls the rendering while the child controls the environment.

function MouseTracker({ render }: { render: (pos: { x: number; y: number }) => React.ReactNode }) {
const [position, setPosition] = useState({ x: 0, y: 0 })
useEffect(() => {
const handler = (e: MouseEvent) => setPosition({ x: e.clientX, y: e.clientY })
window.addEventListener('mousemove', handler)
return () => window.removeEventListener('mousemove', handler)
}, [])
return <div>{render(position)}</div>
}
// Usage
function App() {
return (
<MouseTracker render={({ x, y }) => (
<p>Mouse is at {x}, {y}</p>
)} />
)
}

The caller gets the mouse position without MouseTracker knowing anything about how it’s displayed. Render props invert the ownership of the UI — the data source stays generic, and the consumer decides the rendering.

This pattern was more common before hooks. useMousePosition() is now a simpler abstraction. But render props still appear in existing codebases and library APIs (React Router’s <Route render={...}> variant, for example).

Slots are named children. Instead of a single children prop, a component accepts multiple props, each containing JSX for a specific region.

function Card({ title, body, footer }: {
title: React.ReactNode
body: React.ReactNode
footer?: React.ReactNode
}) {
return (
<div className="card">
<div className="card-title">{title}</div>
<div className="card-body">{body}</div>
{footer && <div className="card-footer">{footer}</div>}
</div>
)
}
function ProfileCard({ user }: { user: User }) {
return (
<Card
title={<Avatar user={user} />}
body={<UserDetails user={user} />}
footer={<UserActions user={user} />}
/>
)
}

Each slot gets its own props directly from the composition caller. No intermediate component passes through props it doesn’t need. The Card component is a pure layout shell.

Composition and lifting state up both solve the sibling-communication problem, but they do it differently. Lifting moves state to a parent and drills it down. Composition moves the consumer up — the data owner renders the data consumer directly, bypassing intermediates.

The decision rule: if an intermediate component exists only to forward props, replace it with composition. Instead of <Layout><Sidebar><NavItem /></Sidebar></Layout> with props going through Layout and Sidebar, have the parent that owns the state render the NavItem directly and pass it as a child prop.

// Before: drilling theme through Layout and Sidebar
function App() {
const [theme] = useState('dark')
return <Layout theme={theme} />
}
// After: composition
function App() {
const [theme] = useState('dark')
return (
<Layout>
<NavItem theme={theme} />
</Layout>
)
}