Search across all documentation pages
🤖 Read the SystemsArchitect.io Blog for over 100+ cloud architecture articles 🔥
// 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>.
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:
AccordionContext for shared state, ItemContext for per-item stateuse() (React 19) or useContext().Select.Option).| 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 |
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>
);
}null and check for null in the consumer hook.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 is null.
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 via ReactNode.
| 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 |
<Tabs>/<Tab>, <Accordion>/<AccordionItem>, and <Select>/<Option>.use() (React 19) or useContext().<Select.Option> reads as a declarative relationship.// 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>AccordionContext provides shared state for all items.ItemContext scopes per-item identity so Trigger and Content know which item they belong to.null, causing silent bugs or crashes.null.if (!ctx) throw new Error("Option must be used within Select");createContext, use(), or useState."use client" and accept Server Component children via ReactNode.interface AccordionContextValue {
openItems: Set<string>;
toggle: (id: string) => void;
multiple: boolean;
}
const AccordionContext = createContext<AccordionContextValue | null>(null);null and use null as the default.null in the custom hook and throw if missing.Accordion.Item = Item can cause type errors because function components don't have a static property type by default.<div> elements will still consume the parent's context.React.Children to inspect direct children.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>
);
}<Tabs items={[...]} />) when the consumer does not need control over layout or structure.useMemo to keep its reference stable.Reviewed by Chris St. John·Last updated Jul 10, 2026
🤖 Read the SystemsArchitect.io Blog for over 100+ cloud architecture articles 🔥