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
PatternGood forCost
childrenWrapping layout and unknown contentNone - it is the default
Named slot propsA small fixed set of regionsGrows as regions are added
Compound componentsWidgets whose parts must share stateImplicit context; the parts must be used together
Render propThe parent owns the data, the caller owns the markupOne extra nesting level
Headless hookBehaviour reused with fully custom markupThe 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.

Context, refs and escape hatches Custom hooks and sharing logic

Last refreshed 2026-09-18.