React SME Cookbook
All FAQs

Search Documentation

Search across all documentation pages

compound-componentscontextapi-designreact-patterns

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: AccordionContext for shared state, ItemContext for 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) or useContext().
  • 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 RoleResponsibilities
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 null and check for null in 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 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.

Alternatives

ApproachTrade-off
Compound componentsBeautiful API, flexible; requires context setup
Configuration object<Tabs items={[...]}/> — simpler but less flexible layout control
Render propsExplicit data flow; more boilerplate at call site
Headless hooksNo JSX opinion; consumer builds everything from scratch
Slot-based compositionSimpler; 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) or useContext().
  • 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>
  • AccordionContext provides shared state for all items.
  • ItemContext scopes 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(), or useState.
  • Fix: mark the compound component file with "use client" and accept Server Component children via ReactNode.
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 null and use null as the default.
  • Check for null in 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 = Item can 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.Children to 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 useMemo to keep its reference stable.
  • Start with a single context and split only when profiling reveals performance issues.