Component composition and reusable APIs
Children as an API, named slots, compound components that share state, render props, headless hooks, and when a component should be split.
Children and named slots
function Card({ title, actions, children }) {
return (
<article className="card">
{title && <header className="card-head">{title}</header>}
<div className="card-body">{children}</div>
{actions && <footer className="card-foot">{actions}</footer>}
</article>
)
}
// an element passed as a prop is still just a value, so it can be built by the caller
<Card
title={<h3>Release notes</h3>}
actions={<button type="button">Copy link</button>}
>
<p>Composition beats a prop for every variant.</p>
</Card>
// when the parent owns the data, let the caller own the markup
function List({ items, children }) {
return (
<ul>
{items.map((item, index) => (
<li key={item.id}>{children(item, index)}</li>
))}
</ul>
)
}
<List items={guides}>{guide => <a href={'/guides/' + guide.slug}>{guide.title}</a>}</List>Every prop you add is a branch the component must keep working. A Card with header, footer and body props grows with each layout change, while one that takes children plus a single slot adapts to layouts you have not imagined yet.
Compound components
import { createContext, useContext, useId, useState } from 'react'
const TabsContext = createContext(null)
function Tabs({ children, defaultValue }) {
const [value, setValue] = useState(defaultValue)
return (
<TabsContext.Provider value={{ value, setValue }}>
<div className="tabs">{children}</div>
</TabsContext.Provider>
)
}
function TabList({ children }) {
return <div role="tablist">{children}</div>
}
function Tab({ value, children }) {
const { value: active, setValue } = useContext(TabsContext)
const id = useId()
return (
<button
type="button"
role="tab"
id={id}
aria-selected={active === value}
onClick={() => setValue(value)}
>
{children}
</button>
)
}
function TabPanel({ value, children }) {
const { value: active } = useContext(TabsContext)
return active === value ? <div role="tabpanel">{children}</div> : null
}
Tabs.List = TabList
Tabs.Tab = Tab
Tabs.Panel = TabPanel
export default Tabs| Pattern | Good for | Cost |
|---|---|---|
children | Wrapping layout and unknown content | None - it is the default |
| Named slot props | A small fixed set of regions | Grows as regions are added |
| Compound components | Widgets whose parts must share state | Implicit context; the parts must be used together |
| Render prop | The parent owns the data, the caller owns the markup | One extra nesting level |
| Headless hook | Behaviour reused with fully custom markup | The caller must wire up markup and accessibility |
Render props and headless hooks
// a headless hook returns behaviour; the component decides the markup
function useDisclosure(initial = false) {
const [open, setOpen] = useState(initial)
const toggle = () => setOpen(value => !value)
return { open, toggle, buttonProps: { 'aria-expanded': open, onClick: toggle } }
}
function FaqItem({ question, answer }) {
const { open, buttonProps } = useDisclosure()
return (
<div>
<button type="button" {...buttonProps}>{question}</button>
{open && <div>{answer}</div>}
</div>
)
}
// a render prop is the same idea when the parent owns the data
function DataLoader({ url, render }) {
const [data, setData] = useState(null)
useEffect(() => {
const controller = new AbortController()
fetch(url, { signal: controller.signal })
.then(response => response.json())
.then(setData)
.catch(() => {})
return () => controller.abort()
}, [url])
return data ? render(data) : <p role="status">Loading...</p>
}- A component is worth splitting when a part has its own reason to change, when it is reused, or when the parent's render body no longer fits on one screen - not by line count alone.
- A hook can carry the behaviour and the accessibility attributes together, so a caller cannot forget
aria-expanded. - Prefer the hook over the render prop for new code: no extra component boundary, no nesting, and the caller keeps full control of the markup.
- Keep the shared state in the closest common parent. Lifting it further is what turns a small widget into a global store.
💡
Reach for children and slots first, compound components when the parts genuinely share state, and a hook when the caller wants different markup. Every pattern beyond that should answer a question the simpler one cannot.
FAQ
When should a component be split in two?
When a part has its own reason to change, is reused elsewhere, or makes the parent hard to read. Splitting for a fixed line count produces prop-drilling and files you have to jump between for no benefit.
Are compound components worth the extra context?
When the parts must share state and are always used together, such as tabs or an accordion. If the parts are independent, explicit props are simpler and easier to test.
Related
Context, refs and escape hatches Custom hooks and sharing logic
Last refreshed 2026-09-18.