compound-pattern
Compound Component Pattern
Compound components expose a parent + a set of child components that work together
through shared internal state. The consumer controls the structure declaratively — exactly
like HTML's <table> / <thead> / <tbody> / <tr> / <td>, where the browser uses
the structure you declare to construct a well-behaved table — while the components
themselves handle the behavior.
Sources: jjenzz.com/compound-components · patterns.dev/react/compound-pattern
Why choose compound components?
The alternative — a single "God component" driven by config props — has real costs:
| Problem with God components | Compound solution |
|---|---|
| Consumer must transform data into the format the component expects | Consumer renders their data however they want — no transformation needed |
| Every new feature needs a new prop and a new release | Consumer binds directly to the sub-component they need |
Props proliferate with prefixes: rowClassName, cellClassName, onRowClick, onCellClick… |
Each sub-component accepts its own standard HTML/React props |
| Hard to visualize what renders by reading the JSX | The tree IS the output — what you write is what you get |
"If you find yourself repeating a prefix amongst your props, try converting that prefix into a child component. It will give more control to the consumer and mean less maintenance for you in the long run." — jjenzz.com
Example contrast:
// ❌ God component — must transform data, prop explosion
<Table
caption="Cats"
columns={columns}
rowData={cats}
rowClassName="table-row"
cellClassName="table-cell"
onRowClick={handleRowClick}
/>
// ✅ Compound — consumer places and styles each part directly
<Table>
<TableCaption>Cats</TableCaption>
<TableHead>
<TableRow>
<TableCell>Name</TableCell>
<TableCell>Breed</TableCell>
</TableRow>
</TableHead>
<TableBody>
{cats.map(cat => (
<TableRow key={cat.id} className="table-row" onClick={handleRowClick}>
<TableCell className="table-cell">{cat.name}</TableCell>
<TableCell className="table-cell">{cat.breed}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
Need an onCellClick for just one cell? With compound components the consumer binds to
that cell directly — no need to release a new version with a new prop.
Two implementation approaches
1. Context API (preferred)
Shares state at any depth — child components don't need to be direct children of the parent. Internal state stays private; consumers can't accidentally break consistency.
import { createContext, useContext, useState, useMemo } from 'react';
// --- Internal context (NOT exported) ---
const FlyOutContext = createContext<{ open: boolean; toggle: () => void } | null>(null);
function useFlyOut() {
const ctx = useContext(FlyOutContext);
if (!ctx) throw new Error('FlyOut sub-components must be used inside <FlyOut>');
return ctx;
}
// --- Parent: owns the state ---
function FlyOut({ children }: { children: React.ReactNode }) {
const [open, setOpen] = useState(false);
const value = useMemo(
() => ({ open, toggle: () => setOpen(o => !o) }),
[open]
);
return (
<FlyOutContext.Provider value={value}>
{children}
</FlyOutContext.Provider>
);
}
// --- Sub-components: consume shared state ---
function Toggle() {
const { toggle } = useFlyOut();
return <button onClick={toggle}>☰</button>;
}
function List({ children }: { children: React.ReactNode }) {
const { open } = useFlyOut();
return open ? <ul>{children}</ul> : null;
}
function Item({ children }: { children: React.ReactNode }) {
return <li>{children}</li>;
}
// --- Attach sub-components as static properties ---
FlyOut.Toggle = Toggle;
FlyOut.List = List;
FlyOut.Item = Item;
export { FlyOut };
Usage — the consumer imports a single thing and gets all sub-parts via dot notation:
import { FlyOut } from './FlyOut';
export function FlyoutMenu() {
return (
<FlyOut>
<FlyOut.Toggle />
<FlyOut.List>
<FlyOut.Item>Edit</FlyOut.Item>
<FlyOut.Item>Delete</FlyOut.Item>
</FlyOut.List>
</FlyOut>
);
}
2. React.Children + cloneElement (limited use)
Passes state as props by cloning each direct child. Simpler for tiny cases, but has real constraints documented by patterns.dev:
- Only direct children receive the injected props — wrap them in a
<div>and they lose access immediately. - Props are shallowly merged, so naming collisions silently overwrite values.
- The injected props leak into the public API — consumers can read them.
export function FlyOut(props: { children: React.ReactNode }) {
const [open, setOpen] = React.useState(false);
const toggle = () => setOpen(o => !o);
return (
<div>
{React.Children.map(props.children, child =>
React.cloneElement(child as React.ReactElement, { open, toggle })
)}
</div>
);
}
// ❌ Breaks — Toggle and List are no longer direct children
<FlyOut>
<div>
<FlyOut.Toggle />
<FlyOut.List>...</FlyOut.List>
</div>
</FlyOut>
Prefer Context API in almost every real case. Use cloneElement only for simple
presentational parent/child relationships with no deep nesting.
Key design decisions
Keep context private
Don't export the context object — only export the custom hook (useFlyOut) if children
need to be consumed outside the file. This prevents consumers from reading or injecting
state they shouldn't control.
Guard hook for early, clear errors
function useFlyOut() {
const ctx = useContext(FlyOutContext);
if (!ctx) throw new Error('FlyOut sub-components must be used inside <FlyOut>');
return ctx;
}
Memoize context value to avoid extra renders
When the provider re-renders for unrelated reasons, an inline value={{ open, toggle }}
creates a new object each render, forcing all consumers to re-render too:
// ❌ New object every render
<FlyOutContext.Provider value={{ open, toggle }}>
// ✅ Stable reference — only re-renders consumers when open or toggle actually changes
const value = useMemo(() => ({ open, toggle }), [open, toggle]);
<FlyOutContext.Provider value={value}>
For fine-grained control, split into two contexts: one for the state (open), one for
the setter (toggle). Components that only dispatch will never re-render on state changes.
Server Components (React 18+/19+)
Context providers and useState are client-only. Mark the file with 'use client'.
Static sub-components (like Item if it holds no state) can remain server components
if extracted to separate files — only the stateful shell needs 'use client'.
Common use-cases for this pattern
- Dropdown / FlyOut menu — Toggle + List + Item (the canonical example)
- Tabs — Tabs + TabList + Tab + TabPanel
- Accordion — Accordion + AccordionItem + AccordionTrigger + AccordionContent
- Modal / Dialog — Dialog + DialogTrigger + DialogContent + DialogTitle + DialogClose
- Data table — Table + TableHead + TableBody + TableRow + TableCell
- Select / Combobox — Select + SelectTrigger + SelectContent + SelectItem
- Form — Form + FormField + FormLabel + FormMessage
"You'll often see this pattern when using UI libraries like Semantic UI." — patterns.dev
When NOT to use it
- The component has only one "part" with no meaningful sub-structure → plain component.
- Children are purely presentational and share no state → just use the
childrenprop. - You need a data-driven API for many repetitive items (e.g. 200-row virtual list) → consider a hybrid: compound wrapper + virtualized inner renderer.
Building checklist
- Define the state that needs to be shared and put it in the parent.
- Create a context (not exported) and a
useXxxguard hook. - Build each sub-component so it reads only what it needs from context.
- Attach sub-components as static properties (
Parent.Child = Child). - Export only the parent (and optionally named exports for individual sub-components).
- Memoize the context value if the parent can re-render for unrelated reasons.
- Write the usage example first — does the JSX feel natural and readable? If not, reconsider the API before implementing.
Quick diagnosis
| Signal | Recommendation |
|---|---|
| You're passing config arrays/objects as props | Convert into child components |
| Multiple props share the same prefix | That prefix is a child component |
The return requires mental abstraction to visualize |
Switch to compound components |
| Children must be in a specific order or format | Consider a data-driven API instead |
"If you find yourself passing config objects/arrays as props, consumers will often have to transform their data first. A compound component can prevent that overhead." — jjenzz.com
More from alexeira/skills
better-forms
Complete guide for building accessible, high-UX forms in modern stacks (React/Next.js, Tailwind, Zod). Includes specific patterns for clickable areas, range sliders, output-inspired design, and WCAG compliance.
42clack
Build and maintain interactive Node.js/Bun CLIs with Clack (`@clack/prompts` and `@clack/core`). Use when an AI agent needs to design prompt flows, implement cancellation-safe input handling, add spinners/log/note UX, apply Clack best practices, or generate runnable examples aligned with official Clack docs.
28clarify-first
Interrogate the user with concrete multiple-choice questions before executing non-trivial tasks. Use when the user says "tengo que hacer X", "necesito implementar Y", "ayúdame a construir Z", "I need to build X", "how should I approach Y", or describes a task without clear scope, success criteria, or chosen approach. Forces clarifying questions about scope, success criteria, user's prior knowledge, and tradeoffs BEFORE any code is written. Prevents over-delegation (handing a half-understood task to the LLM) and analysis paralysis. User may invoke and respond in Spanish or English — match their language. Do NOT use for trivial edits, typo fixes, single-line changes, or when the user explicitly says "just do it" / "hazlo".
1