Skip to main content

Compound Components: Build Cohesive Component Groups

Compound components are a set of related child components that manage internal state and behavior together, exposing a clean, self-documenting API where the parent controls layout and composition. Rather than passing state through props at every level, compound components share that state implicitly through context, letting the parent treat the children as a unit. A modal, accordion, or form stepper are natural compound components: the parent doesn't care about the internal coordination; it just composes the pieces and lets them talk to each other.

I first encountered compound components in the Reach UI library and immediately recognized why teams using them shipped faster: the component API was so clear that developers didn't need to ask how to use it. <Modal>, <Modal.Header>, <Modal.Body>, <Modal.Footer> told you everything you needed to know.

How Compound Components Work

The Core Pattern: Context + Controlled Composition

A compound component uses React Context to share state among children, and the parent composes those children as static properties. The parent component manages state; each child taps into that context to read or update shared data.

// Step 1: Create a context to hold the shared state
const AccordionContext = React.createContext();

// Step 2: The parent component (AccordionButton provides context)
export function Accordion({ children, defaultOpen = false }) {
const [open, setOpen] = React.useState(defaultOpen);

return (
<AccordionContext.Provider value={{ open, setOpen }}>
<div className="accordion">
{children}
</div>
</AccordionContext.Provider>
);
}

// Step 3: Child components read from context
function AccordionHeader({ children }) {
const { setOpen } = React.useContext(AccordionContext);
return (
<button onClick={() => setOpen(prev => !prev)}>
{children}
</button>
);
}

function AccordionPanel({ children }) {
const { open } = React.useContext(AccordionContext);
return open && <div className="panel">{children}</div>;
}

// Step 4: Attach children as static properties
Accordion.Header = AccordionHeader;
Accordion.Panel = AccordionPanel;

// Usage: composition is self-documenting
export default function App() {
return (
<Accordion>
<Accordion.Header>Click me</Accordion.Header>
<Accordion.Panel>Content inside</Accordion.Panel>
</Accordion>
);
}

This pattern eliminates the problem of "which props go where?" The structure of the JSX mirrors the data flow: Accordion manages state, its children consume it.

Building a Real-World Form Group

Many teams rediscover this pattern when building form components. A form field typically needs a label, input, error message, and help text—four pieces that should share validation state but remain composable.

// FormField parent manages validation state
const FormFieldContext = React.createContext();

export function FormField({ children, name, onValidate }) {
const [error, setError] = React.useState(null);
const [touched, setTouched] = React.useState(false);

const handleValidate = (value) => {
const result = onValidate?.(value);
if (result) {
setError(result);
} else {
setError(null);
}
};

return (
<FormFieldContext.Provider
value={{ error, touched, setTouched, handleValidate, name }}
>
<fieldset className="form-field">
{children}
</fieldset>
</FormFieldContext.Provider>
);
}

// Label accesses name from context
FormField.Label = function({ children }) {
const { name } = React.useContext(FormFieldContext);
return <label htmlFor={name}>{children}</label>;
};

// Input calls validation on blur, reads error state
FormField.Input = function(props) {
const { name, handleValidate, setTouched } = React.useContext(FormFieldContext);

const handleBlur = () => {
setTouched(true);
handleValidate(props.defaultValue || '');
};

return (
<input
id={name}
name={name}
{...props}
onBlur={handleBlur}
aria-invalid={!!error}
aria-describedby={`${name}-error`}
/>
);
};

// Error reads context and only shows when touched
FormField.Error = function() {
const { error, touched } = React.useContext(FormFieldContext);
if (!touched || !error) return null;
return <span className="error">{error}</span>;
};

// Example: form that automatically validates without prop threading
function EmailField() {
return (
<FormField
name="email"
onValidate={(val) => {
if (!val.includes('@')) return 'Invalid email';
return null;
}}
>
<FormField.Label>Email Address</FormField.Label>
<FormField.Input type="email" />
<FormField.Error />
</FormField>
);
}

Now the parent doesn't pass error, touched, or validation callbacks down a chain; the compound structure handles it. Adding a new child component (like a character-count indicator) requires zero prop changes to the parent.

Managing Multiple Child Instances

What if your compound component needs multiple instances of a child? For example, a tabs component with multiple tab panels?

const TabsContext = React.createContext();

export function Tabs({ children, defaultActive = 0 }) {
const [activeIndex, setActiveIndex] = React.useState(defaultActive);

// Find all Tab children to pass them metadata
const tabCount = React.Children.count(children);

return (
<TabsContext.Provider value={{ activeIndex, setActiveIndex, tabCount }}>
<div className="tabs">
{children}
</div>
</TabsContext.Provider>
);
}

Tabs.List = function({ children }) {
return <div className="tabs-list" role="tablist">{children}</div>;
};

Tabs.Tab = function({ children, index }) {
const { activeIndex, setActiveIndex } = React.useContext(TabsContext);
return (
<button
onClick={() => setActiveIndex(index)}
role="tab"
aria-selected={activeIndex === index}
className={activeIndex === index ? 'active' : ''}
>
{children}
</button>
);
};

Tabs.Panel = function({ children, index }) {
const { activeIndex } = React.useContext(TabsContext);
return activeIndex === index && <div role="tabpanel">{children}</div>;
};

// Usage: explicit index prop for each tab
export default function App() {
return (
<Tabs defaultActive={0}>
<Tabs.List>
<Tabs.Tab index={0}>Profile</Tabs.Tab>
<Tabs.Tab index={1}>Settings</Tabs.Tab>
<Tabs.Tab index={2}>Billing</Tabs.Tab>
</Tabs.List>
<Tabs.Panel index={0}>Profile content</Tabs.Panel>
<Tabs.Panel index={1}>Settings content</Tabs.Panel>
<Tabs.Panel index={2}>Billing content</Tabs.Panel>
</Tabs>
);
}

Note: passing explicit index props is a bit verbose. Some teams use a hidden counter with a custom hook to auto-increment, but the explicit version is more predictable.

Key Takeaways

  • Compound components use Context to share state among related children, eliminating prop drilling.
  • Each child is a static property of the parent, making the API self-documenting and composition order-independent.
  • Compound components excel for modular, cohesive widget groups (modals, form fields, tabs, accordions).
  • The pattern encourages separation of concerns: the parent owns state; children own presentation and interaction.
  • Use explicit props (like index) for clarity when managing multiple child instances.

Frequently Asked Questions

Do I have to use Context for compound components?

Context is the standard, but some teams pass a value object as a prop. However, Context is cleaner for deeply nested children and avoids prop-drilling. For most use cases, Context is the right tool.

What if a child component isn't used inside the compound parent?

The child will fail when trying to call useContext() on a missing provider. You can add a check: const context = useContext(MyContext); if (!context) throw new Error('Use this inside <MyComponent>') to give a clear error message.

Can I use compound components with TypeScript?

Yes. Declare the static properties as functions in the parent component, then type them properly. Example: Accordion.Header = AccordionHeader as React.FC<HeaderProps>. You get full type checking on the component API.

When should I choose compound components over render props?

Compound components are best when you have a fixed set of related pieces (header, body, footer). Render props are better when you need flexible rendering or conditional logic. Many real-world cases benefit from both.

Are compound components the same as context?

No. Context is a mechanism for sharing data; compound components are an architectural pattern that uses Context (usually). You could technically use other mechanisms, but Context is the idiomatic choice in modern React.

Further Reading