Compound components let a group of related components share implicit state through Context, giving consumers full control over layout and composition. Think of how `'); return ctx; } function Select({ children, onChange }: { children: ReactNode; onChange?: (v: string) => void }) { const [isOpen, setOpen] = useState(false); const [selectedValue, setSelectedValue] = useState(null); const [highlightedIndex, setHighlightedIndex] = useState(0); const [options, setOptions] = useState([]); const registerOption = (value: string) => { setOptions(prev => prev.includes(value) ? prev : [...prev, value]); }; const select = (value: string) => { setSelectedValue(value); setOpen(false); onChange?.(value); }; const handleKeyDown = (e: React.KeyboardEvent) => { if (e.key === 'ArrowDown') setHighlightedIndex(i => Math.min(i + 1, options.length - 1)); if (e.key === 'ArrowUp') setHighlightedIndex(i => Math.max(i - 1, 0)); if (e.key === 'Enter' && isOpen) select(options[highlightedIndex]); if (e.key === 'Escape') setOpen(false); }; return (
{children}
); } function Trigger({ children, placeholder }: { children?: ReactNode; placeholder?: string }) { const { isOpen, setOpen, selectedValue } = useSelect(); return ( ); } function Options({ children }: { children: ReactNode }) { const { isOpen } = useSelect(); if (!isOpen) return null; return ; } function Option({ value, children }: { value: string; children: ReactNode }) { const { select, selectedValue, registerOption } = useSelect(); useEffect(() => { registerOption(value); }, [value]); return (
  • select(value)} className={selectedValue === value ? 'option selected' : 'option'} > {children}
  • ); } Select.Trigger = Trigger; Select.Options = Options; Select.Option = Option; // Usage ``` A Select component demonstrates a more complex compound pattern. The parent manages open state, selection, keyboard navigation, and an option registry. Each Option registers itself on mount so the parent knows about all available options for keyboard navigation. This is a simplified version of what libraries like Radix UI and Headless UI ship. ### Modal with Header, Body, Footer slots tsxCopy ``` import { createContext, useContext, ReactNode, useEffect } from 'react'; import { createPortal } from 'react-dom'; type ModalCtx = { onClose: () => void; }; const ModalCtx = createContext(null); function useModal() { const ctx = useContext(ModalCtx); if (!ctx) throw new Error('Modal.* must be used within '); return ctx; } type ModalProps = { isOpen: boolean; onClose: () => void; children: ReactNode; }; function Modal({ isOpen, onClose, children }: ModalProps) { useEffect(() => { if (!isOpen) return; const handleEscape = (e: KeyboardEvent) => { if (e.key === 'Escape') onClose(); }; document.addEventListener('keydown', handleEscape); document.body.style.overflow = 'hidden'; return () => { document.removeEventListener('keydown', handleEscape); document.body.style.overflow = ''; }; }, [isOpen, onClose]); if (!isOpen) return null; return createPortal(
    e.stopPropagation()} > {children}
    , document.body ); } function Header({ children }: { children: ReactNode }) { const { onClose } = useModal(); return (
    {children}
    ); } function Body({ children }: { children: ReactNode }) { return
    {children}
    ; } function Footer({ children }: { children: ReactNode }) { return
    {children}
    ; } Modal.Header = Header; Modal.Body = Body; Modal.Footer = Footer; // Usage setShowConfirm(false)}>

    Delete project?

    This will permanently delete {project.name} and all its data.

    ``` The Modal shares onClose through Context so the Header can render a close button without the consumer wiring it up. The Footer is optional — if you only need a confirmation message, skip it entirely. Portals ensure the modal escapes parent overflow and z-index stacking. This pattern shows compound components working beyond just state sharing — they also provide structural conventions. ### Controlled mode — letting parents own the state tsxCopy ``` type TabsProps = { // Uncontrolled: component owns state defaultTab?: string; // Controlled: parent owns state activeTab?: string; onTabChange?: (id: string) => void; children: ReactNode; }; function Tabs({ defaultTab, activeTab: controlledTab, onTabChange, children }: TabsProps) { const [internalTab, setInternalTab] = useState(defaultTab ?? ''); const isControlled = controlledTab !== undefined; const activeTab = isControlled ? controlledTab : internalTab; const setActiveTab = (id: string) => { if (!isControlled) setInternalTab(id); onTabChange?.(id); }; return (
    {children}
    ); } // Uncontrolled — fire and forget Overview API ... ... // Controlled — parent syncs with URL params const [tab, setTab] = useState(searchParams.get('tab') ?? 'overview'); { setTab(id); setSearchParams({ tab: id }); }}> {/* same children */} ``` Supporting both modes is essential for real-world compound components. Uncontrolled mode is convenient for static UIs. Controlled mode is necessary when the active state needs to sync with URL parameters, form state, or other components. The pattern checks whether a controlled value is provided and delegates accordingly. ## Common Mistakes Mistake: Using \`React.Children.map\` and \`cloneElement\` to inject props into children instead of using Context Fix: Context works regardless of nesting depth and does not break when consumers wrap sub-components in divs, fragments, or custom wrappers. cloneElement only works on direct children and silently fails when the tree structure changes. Context is the correct primitive for compound components. Mistake: Failing to throw an error when a sub-component is used outside its parent provider — returning null or using a default value instead Fix: Always throw a descriptive error in the custom hook: \`throw new Error('Tabs.Tab must be used within ')\`. Silent failures lead to hours of debugging. The developer needs to know immediately that they placed a component in the wrong part of the tree. Mistake: Putting too much into the Context value — exposing internal state, dispatch functions, and derived values that sub-components never use Fix: Keep the context value minimal. If some sub-components need data that others do not, consider splitting into two contexts (one for state, one for dispatch) or providing only what the public sub-components actually read. A bloated context causes unnecessary re-renders across all consumers. Mistake: Creating a new context value object on every render, which triggers re-renders in all consumers even when the actual state has not changed Fix: Memoize the context value with \`useMemo\`: \`const value = useMemo(() => ({ activeTab, setActiveTab }), \[activeTab\])\`. This ensures consumers only re-render when the state they depend on actually changes. ## Best Practices - Use the dot notation pattern (\`Tabs.Tab\`, \`Tabs.Panel\`) to attach sub-components to the parent. This makes the API self-documenting — autocomplete shows all available sub-components when you type \`Tabs.\` - Support both controlled and uncontrolled modes. Accept \`defaultValue\` for uncontrolled usage and \`value\` + \`onChange\` for controlled usage. Check if the controlled prop is defined to determine which mode to use. - Memoize the Context value with \`useMemo\` to prevent unnecessary re-renders. This matters especially when the parent component has props or state unrelated to the compound component's shared state. - Write a custom hook (e.g., \`useTabs\`, \`useAccordion\`) that throws on null context instead of using \`useContext\` directly in sub-components. This centralizes error handling and gives you a single place to add logging or validation. - Keep sub-components focused on one responsibility. The Trigger handles click events, the Content handles visibility, the Item provides grouping. Do not merge responsibilities — it defeats the composability that makes this pattern valuable. ## Summary Compound components are a group of related React components that share implicit state through Context, giving consumers full control over markup and layout. The parent component provides state via a Context Provider, and sub-components consume it through a custom hook. This pattern replaces sprawling props-based APIs with a composable, declarative interface. Attach sub-components using dot notation for discoverability, throw clear errors when components are used outside their parent, memoize the context value, and support both controlled and uncontrolled modes. Every serious React component library — Radix, Headless UI, Ark UI — builds on this pattern. ## Go deeper with Stanza Practice react compound components with interactive lessons and challenges. [React Components & Patterns](https://www.stanza.dev/courses/react-components-patterns) [React 19 & Patterns](https://www.stanza.dev/courses/react-modern-patterns) ## Related Concepts [React Context Api](https://www.stanza.dev/concepts/react-context-api "React Context Api") [React Custom Hooks](https://www.stanza.dev/concepts/react-custom-hooks "React Custom Hooks") [React Usestate](https://www.stanza.dev/concepts/react-usestate "React Usestate") [React Error Boundaries](https://www.stanza.dev/concepts/react-error-boundaries "React Error Boundaries") ## Related Cheatsheets [React Hooks Cheatsheet](https://www.stanza.dev/cheatsheet/react-hooks "React Hooks Cheatsheet")