Compound Components — Design multi-part component APIs that share implicit state through context
Recipe
// Compound component: Select with shared state
const SelectContext = createContext<{
value: string;
onChange: (value: string) => void;
} | null>(null);
function Select({ value, onChange, children }: {
value: string;
onChange: (value: string) => void;
children: React.ReactNode;
}) {
return (
<SelectContext value={{ value, onChange }}>
<div role="listbox">{children}</div>
</SelectContext>
);
}
function Option({ value, children }: { value: string; children: React.ReactNode }) {
const ctx = use(SelectContext);
if (!ctx) throw new Error("Option must be used within Select");
const isSelected = ctx.value === value;
return (
<div
role="option"
aria-selected={isSelected}
onClick={() => ctx.onChange(value)}
className={isSelected ? "bg-blue-100 font-semibold" : ""}
>
{children}
</div>
);
}
Select.Option = Option;
// Usage — reads like a declarative API
<Select value={selected} onChange={setSelected}>
<Select.Option value="react">React</Select.Option>
<Select.Option value="vue">Vue</Select.Option>
<Select.Option value="svelte">Svelte</Select.Option>
</Select>When to reach for this: When building a multi-part component where sub-components need shared state but the consumer should control structure and order. Think <Tabs>/<Tab>, <Accordion>/<AccordionItem>, <Menu>/<MenuItem>.
Working Example
import { createContext, use, useState, useId, type ReactNode } from "react";
// --- Accordion compound component ---
interface AccordionContextValue {
openItems: Set<string>;
toggle: (id: string) => void;
multiple: boolean;
}
const AccordionContext = createContext<AccordionContextValue | null>(null);
function useAccordion() {
const ctx = use(AccordionContext);
if (!ctx) throw new Error("Accordion sub-components must be used within <Accordion>");
return ctx;
}
// Root
function Accordion({
children,
multiple = false,
defaultOpen = [],
}: {
children: ReactNode;
multiple?: boolean;
defaultOpen?: string[];
}) {
const [openItems, setOpenItems] = useState<Set<string>>(
() => new Set(defaultOpen)
);
const toggle = (id: string) => {
setOpenItems((prev) => {
const next = new Set(prev);
if (next.has(id)) {
next.delete(id);
} else {
if (!multiple) next.clear();
next.add(id);
}
return next;
});
};
return (
<AccordionContext value={{ openItems, toggle, multiple }}>
<div className="divide-y border rounded-lg">{children}</div>
</AccordionContext>
);
}
// Item
interface ItemContextValue {
itemId: string;
isOpen: boolean;
}
const ItemContext = createContext<ItemContextValue | null>(null);
function Item({ id, children }: { id: string; children: ReactNode }) {
const { openItems } = useAccordion();
const isOpen = openItems.has(id);
return (
<ItemContext value={{ itemId: id, isOpen }}>
<div>{children}</div>
</ItemContext>
);
}
function useItem() {
const ctx = use(ItemContext);
if (!ctx) throw new Error("Must be used within <Accordion.Item>");
return ctx;
}
// Trigger
function Trigger({ children }: { children: ReactNode }) {
const { toggle } = useAccordion();
const { itemId, isOpen } = useItem();
const contentId = `accordion-content-${itemId}`;
return (
<button
className="w-full text-left p-4 flex justify-between items-center"
onClick={() => toggle(itemId)}
aria-expanded={isOpen}
aria-controls={contentId}
>
{children}
<span className={`transition-transform ${isOpen ? "rotate-180" : ""}`}>
▼
</span>
</button>
);
}
// Content
function Content({ children }: { children: ReactNode }) {
const { itemId, isOpen } = useItem();
const contentId = `accordion-content-${itemId}`;
if (!isOpen) return null;
return (
<div id={contentId} role="region" className="p-4 pt-0 text-gray-600">
{children}
</div>
);
}
// Attach sub-components
Accordion.Item = Item;
Accordion.Trigger = Trigger;
Accordion.Content = Content;
// --- Usage ---
function FAQPage() {
return (
<Accordion defaultOpen={["general"]}>
<Accordion.Item id="general">
<Accordion.Trigger>What is React?</Accordion.Trigger>
<Accordion.Content>
React is a JavaScript library for building user interfaces.
</Accordion.Content>
</Accordion.Item>
<Accordion.Item id="hooks">
<Accordion.Trigger>What are hooks?</Accordion.Trigger>
<Accordion.Content>
Hooks let you use state and other React features in function components.
</Accordion.Content>
</Accordion.Item>
</Accordion>
);
}What this demonstrates:
- Two-level context:
AccordionContextfor shared state,ItemContextfor per-item state - Clean declarative API at the call site
- Consumers control structure, order, and styling
- ARIA attributes handled internally by the compound component
Deep Dive
How It Works
- A parent component creates a context with shared state and actions.
- Child sub-components consume that context using
use()(React 19) oruseContext(). - Sub-components are attached as static properties on the parent (e.g.,
Select.Option). - The consumer composes these parts in JSX, controlling order and wrapper elements.
- Each sub-component validates it is rendered within the expected parent via a null context check.
Parameters & Return Values
| Component Role | Responsibilities |
|---|---|
Root (e.g. Accordion) | Owns shared state, provides context, renders outer container |
Item wrapper (e.g. Accordion.Item) | Scopes per-item context, maps item identity |
Trigger (e.g. Accordion.Trigger) | Handles user interaction, connects to shared state |
Content (e.g. Accordion.Content) | Conditionally renders based on shared state |
Variations
Flexible child validation — accept sub-components anywhere in the tree, not just as direct children:
// Context-based compound components work at any depth
<Accordion>
<div className="custom-wrapper">
{/* Works because Item reads context, not direct children */}
<Accordion.Item id="nested">
<Accordion.Trigger>Still works</Accordion.Trigger>
<Accordion.Content>Context flows through any depth</Accordion.Content>
</Accordion.Item>
</div>
</Accordion>Controlled compound component — let the parent control open state:
function Accordion({
openItems,
onToggle,
children,
}: {
openItems: Set<string>;
onToggle: (id: string) => void;
children: ReactNode;
}) {
return (
<AccordionContext value={{ openItems, toggle: onToggle, multiple: true }}>
<div>{children}</div>
</AccordionContext>
);
}TypeScript Notes
- Type context values as unions with
nulland check fornullin the consumer hook. - Export sub-component prop types for consumers who need to extend them.
- Use module augmentation or intersection types if attaching sub-components to a function component creates type issues.
Gotchas
-
Sub-component used outside parent — Context will be
null, causing silent bugs or crashes. Fix: Throw a descriptive error in the custom hook when context isnull. -
Stale context with memoized children — If a child is wrapped in
React.memo, it may not re-render when context changes. Fix: Ensure memoized children still consume context directly, not through props. -
Over-splitting context — Creating too many context layers adds complexity. Fix: Start with a single context; split only when profiling reveals unnecessary re-renders.
-
Server component incompatibility — Compound components using context require
"use client". Fix: Mark the compound component file with"use client"and accept Server Component children viaReactNode.
Alternatives
| Approach | Trade-off |
|---|---|
| Compound components | Beautiful API, flexible; requires context setup |
| Configuration object | <Tabs items={[...]}/> — simpler but less flexible layout control |
| Render props | Explicit data flow; more boilerplate at call site |
| Headless hooks | No JSX opinion; consumer builds everything from scratch |
| Slot-based composition | Simpler; sub-parts can't communicate without prop drilling |
FAQs
What is a compound component in React?
- A compound component is a set of related components that share implicit state through context.
- A parent component owns the state and provides it via context; child sub-components consume it.
- Examples include
<Tabs>/<Tab>,<Accordion>/<AccordionItem>, and<Select>/<Option>.
How do compound components share state between sub-components?
- The parent creates a React context with shared state and action functions.
- Sub-components consume the context using
use()(React 19) oruseContext(). - This allows sub-components to communicate without prop drilling.
Why attach sub-components as static properties like Select.Option?
- It creates a clean, discoverable API:
<Select.Option>reads as a declarative relationship. - Consumers can import just the root component and access all sub-components through it.
- It groups related components logically without requiring separate imports.
How does two-level context work in the Accordion example?
// Level 1: AccordionContext — shared state (openItems, toggle)
// Level 2: ItemContext — per-item state (itemId, isOpen)
<AccordionContext value={{ openItems, toggle, multiple }}>
<ItemContext value={{ itemId: id, isOpen }}>
{children}
</ItemContext>
</AccordionContext>AccordionContextprovides shared state for all items.ItemContextscopes per-item identity so Trigger and Content know which item they belong to.
Gotcha: What happens if a sub-component is used outside its parent?
- The context will be
null, causing silent bugs or crashes. - Fix: throw a descriptive error in the custom hook when context is
null. - Example:
if (!ctx) throw new Error("Option must be used within Select");
Gotcha: Why must compound components using context be marked "use client" in Next.js?
- Context requires client-side React to create, provide, and consume state.
- Server Components cannot use
createContext,use(), oruseState. - Fix: mark the compound component file with
"use client"and accept Server Component children viaReactNode.
How do you type a compound component's context value in TypeScript?
interface AccordionContextValue {
openItems: Set<string>;
toggle: (id: string) => void;
multiple: boolean;
}
const AccordionContext = createContext<AccordionContextValue | null>(null);- Type the context as a union with
nulland usenullas the default. - Check for
nullin the custom hook and throw if missing. - Export the context value type so consumers can extend it if needed.
How do you handle TypeScript issues when attaching sub-components to a function component?
- Assigning
Accordion.Item = Itemcan cause type errors because function components don't have a static property type by default. - Fix: use module augmentation, intersection types, or declare the component with an explicit type that includes the static properties.
- Alternatively, export sub-components separately and let consumers import them individually.
Can compound component sub-components be nested at any depth in the JSX tree?
- Yes, because context-based compound components work at any depth, not just as direct children.
- A sub-component wrapped in custom
<div>elements will still consume the parent's context. - This is a key advantage over patterns that rely on
React.Childrento inspect direct children.
How do you make a compound component support controlled mode?
function Accordion({
openItems,
onToggle,
children,
}: {
openItems: Set<string>;
onToggle: (id: string) => void;
children: ReactNode;
}) {
return (
<AccordionContext value={{ openItems, toggle: onToggle, multiple: true }}>
<div>{children}</div>
</AccordionContext>
);
}- Accept the state and callbacks as props from the parent instead of managing them internally.
- The parent becomes the single source of truth for which items are open.
When should you use a configuration object API instead of compound components?
- Use a configuration object (
<Tabs items={[...]} />) when the consumer does not need control over layout or structure. - Use compound components when the consumer needs to control order, wrapper elements, and styling of sub-parts.
- Compound components are more flexible but require more setup.
How do you avoid unnecessary re-renders in compound components when context changes?
- Split context into separate state and action contexts so action-only consumers do not re-render on state changes.
- Memoize the actions object with
useMemoto keep its reference stable. - Start with a single context and split only when profiling reveals performance issues.
Related
- Composition — Foundation pattern that compound components build on
- Context Patterns — Deep dive into context performance and splitting
- Controlled vs Uncontrolled — Making compound components externally controllable