Front-end Engineering Lab
PatternsArchitecture Patterns

Compound Components

Build flexible and composable component APIs like Radix UI

Compound Components Pattern

The Compound Components pattern allows you to create a group of components that work together to form a complete UI element. Used heavily by Radix UI, Headless UI, and React Aria.

🎯 The Problem

// ❌ BAD: Prop explosion
<Select
  trigger="Select option"
  options={[
    { value: '1', label: 'Option 1', icon: '🍎' },
    { value: '2', label: 'Option 2', icon: '🍊' }
  ]}
  onChange={handleChange}
  placeholder="Choose..."
  renderTrigger={(selected) => <CustomTrigger>{selected}</CustomTrigger>}
  renderOption={(option) => <CustomOption>{option}</CustomOption>}
  showCheckmarks={true}
  allowClear={true}
  // Too many props! Hard to customize!
/>

// ✅ GOOD: Compound components
<Select value={value} onChange={onChange}>
  <Select.Trigger>Select option</Select.Trigger>
  <Select.Content>
    <Select.Item value="1">
      <Select.ItemIcon>🍎</Select.ItemIcon>
      <Select.ItemText>Option 1</Select.ItemText>
    </Select.Item>
    <Select.Item value="2">
      <Select.ItemIcon>🍊</Select.ItemIcon>
      <Select.ItemText>Option 2</Select.ItemText>
    </Select.Item>
  </Select.Content>
</Select>

🔧 Implementation

Basic Pattern with Context

import { createContext, useContext, useState } from 'react';

// Step 1: Create context for shared state
interface SelectContextValue {
  value: string;
  onChange: (value: string) => void;
  open: boolean;
  setOpen: (open: boolean) => void;
}

const SelectContext = createContext<SelectContextValue | null>(null);

function useSelectContext() {
  const context = useContext(SelectContext);
  if (!context) {
    throw new Error('Select compound components must be used within Select');
  }
  return context;
}

// Step 2: Root component provides context
interface SelectProps {
  value: string;
  onChange: (value: string) => void;
  children: React.ReactNode;
}

function Select({ value, onChange, children }: SelectProps) {
  const [open, setOpen] = useState(false);
  
  return (
    <SelectContext.Provider value={{ value, onChange, open, setOpen }}>
      <div className="select">{children}</div>
    </SelectContext.Provider>
  );
}

// Step 3: Child components consume context
function SelectTrigger({ children }: { children: React.ReactNode }) {
  const { value, open, setOpen } = useSelectContext();
  
  return (
    <button
      onClick={() => setOpen(!open)}
      className="select-trigger"
    >
      {children}
    </button>
  );
}

function SelectContent({ children }: { children: React.ReactNode }) {
  const { open } = useSelectContext();
  
  if (!open) return null;
  
  return (
    <div className="select-content">
      {children}
    </div>
  );
}

function SelectItem({ 
  value, 
  children 
}: { 
  value: string; 
  children: React.ReactNode;
}) {
  const { value: selectedValue, onChange, setOpen } = useSelectContext();
  const isSelected = value === selectedValue;
  
  return (
    <div
      onClick={() => {
        onChange(value);
        setOpen(false);
      }}
      className={`select-item ${isSelected ? 'selected' : ''}`}
    >
      {children}
      {isSelected && <span>✓</span>}
    </div>
  );
}

// Step 4: Attach components to parent
Select.Trigger = SelectTrigger;
Select.Content = SelectContent;
Select.Item = SelectItem;

export { Select };

Usage

function App() {
  const [value, setValue] = useState('');
  
  return (
    <Select value={value} onChange={setValue}>
      <Select.Trigger>
        {value || 'Select an option'}
      </Select.Trigger>
      
      <Select.Content>
        <Select.Item value="apple">
          🍎 Apple
        </Select.Item>
        <Select.Item value="orange">
          🍊 Orange
        </Select.Item>
        <Select.Item value="banana">
          🍌 Banana
        </Select.Item>
      </Select.Content>
    </Select>
  );
}

🎨 Advanced: Radix UI Style

With Slots Pattern

import { Slot } from '@radix-ui/react-slot';

interface SelectItemProps {
  value: string;
  asChild?: boolean;
  children: React.ReactNode;
}

function SelectItem({ value, asChild, children }: SelectItemProps) {
  const { onChange, setOpen } = useSelectContext();
  const Comp = asChild ? Slot : 'div';
  
  return (
    <Comp
      onClick={() => {
        onChange(value);
        setOpen(false);
      }}
      className="select-item"
    >
      {children}
    </Comp>
  );
}

// Usage with custom element
<Select.Item value="apple" asChild>
  <button className="custom-button">
    🍎 Apple
  </button>
</Select.Item>

With Polymorphic Components

type AsProp<C extends React.ElementType> = {
  as?: C;
};

type PropsToOmit<C extends React.ElementType, P> = keyof (AsProp<C> & P);

type PolymorphicComponentProp<
  C extends React.ElementType,
  Props = {}
> = React.PropsWithChildren<Props & AsProp<C>> &
  Omit<React.ComponentPropsWithoutRef<C>, PropsToOmit<C, Props>>;

function SelectTrigger<C extends React.ElementType = 'button'>({
  as,
  children,
  ...props
}: PolymorphicComponentProp<C>) {
  const { open, setOpen } = useSelectContext();
  const Component = as || 'button';
  
  return (
    <Component
      {...props}
      onClick={() => setOpen(!open)}
      className="select-trigger"
    >
      {children}
    </Component>
  );
}

// Usage
<Select.Trigger as="div">Custom div trigger</Select.Trigger>
<Select.Trigger as={Link} to="/page">Link trigger</Select.Trigger>

🔒 Type-Safe Compound Components

With TypeScript Validation

import { createContext, useContext, useState } from 'react';

// Define allowed children
type TabsContextValue = {
  value: string;
  onChange: (value: string) => void;
};

const TabsContext = createContext<TabsContextValue | null>(null);

interface TabsProps {
  value: string;
  onChange: (value: string) => void;
  children: React.ReactElement<TabsListProps | TabsPanelsProps>[];
}

function Tabs({ value, onChange, children }: TabsProps) {
  return (
    <TabsContext.Provider value={{ value, onChange }}>
      <div className="tabs">{children}</div>
    </TabsContext.Provider>
  );
}

interface TabsListProps {
  children: React.ReactElement<TabProps>[];
}

function TabsList({ children }: TabsListProps) {
  return <div className="tabs-list">{children}</div>;
}

interface TabProps {
  value: string;
  children: React.ReactNode;
}

function Tab({ value, children }: TabProps) {
  const { value: selectedValue, onChange } = useContext(TabsContext)!;
  
  return (
    <button
      onClick={() => onChange(value)}
      className={selectedValue === value ? 'active' : ''}
    >
      {children}
    </button>
  );
}

interface TabsPanelsProps {
  children: React.ReactElement<TabPanelProps>[];
}

function TabsPanels({ children }: TabsPanelsProps) {
  return <div className="tabs-panels">{children}</div>;
}

interface TabPanelProps {
  value: string;
  children: React.ReactNode;
}

function TabPanel({ value, children }: TabPanelProps) {
  const { value: selectedValue } = useContext(TabsContext)!;
  
  if (value !== selectedValue) return null;
  
  return <div className="tab-panel">{children}</div>;
}

Tabs.List = TabsList;
Tabs.Tab = Tab;
Tabs.Panels = TabsPanels;
Tabs.Panel = TabPanel;

// TypeScript ensures correct nesting!
<Tabs value="1" onChange={setValue}>
  <Tabs.List>
    <Tabs.Tab value="1">Tab 1</Tabs.Tab>
    <Tabs.Tab value="2">Tab 2</Tabs.Tab>
  </Tabs.List>
  <Tabs.Panels>
    <Tabs.Panel value="1">Content 1</Tabs.Panel>
    <Tabs.Panel value="2">Content 2</Tabs.Panel>
  </Tabs.Panels>
</Tabs>

🎯 Real-World Example: Accordion

interface AccordionContextValue {
  value: string[];
  onChange: (value: string[]) => void;
  multiple?: boolean;
}

const AccordionContext = createContext<AccordionContextValue | null>(null);

function Accordion({ 
  value, 
  onChange, 
  multiple = false,
  children 
}: {
  value: string[];
  onChange: (value: string[]) => void;
  multiple?: boolean;
  children: React.ReactNode;
}) {
  return (
    <AccordionContext.Provider value={{ value, onChange, multiple }}>
      <div className="accordion">{children}</div>
    </AccordionContext.Provider>
  );
}

function AccordionItem({ 
  value, 
  children 
}: { 
  value: string; 
  children: React.ReactNode;
}) {
  const ItemContext = createContext(value);
  
  return (
    <ItemContext.Provider value={value}>
      <div className="accordion-item">{children}</div>
    </ItemContext.Provider>
  );
}

function AccordionTrigger({ children }: { children: React.ReactNode }) {
  const itemValue = useContext(ItemContext)!;
  const { value, onChange, multiple } = useContext(AccordionContext)!;
  
  const isOpen = value.includes(itemValue);
  
  const toggle = () => {
    if (multiple) {
      onChange(
        isOpen
          ? value.filter(v => v !== itemValue)
          : [...value, itemValue]
      );
    } else {
      onChange(isOpen ? [] : [itemValue]);
    }
  };
  
  return (
    <button onClick={toggle} className="accordion-trigger">
      {children}
      <span>{isOpen ? '▼' : '▶'}</span>
    </button>
  );
}

function AccordionContent({ children }: { children: React.ReactNode }) {
  const itemValue = useContext(ItemContext)!;
  const { value } = useContext(AccordionContext)!;
  
  const isOpen = value.includes(itemValue);
  
  if (!isOpen) return null;
  
  return <div className="accordion-content">{children}</div>;
}

Accordion.Item = AccordionItem;
Accordion.Trigger = AccordionTrigger;
Accordion.Content = AccordionContent;

// Usage
function FAQ() {
  const [value, setValue] = useState<string[]>([]);
  
  return (
    <Accordion value={value} onChange={setValue} multiple>
      <Accordion.Item value="1">
        <Accordion.Trigger>What is React?</Accordion.Trigger>
        <Accordion.Content>
          React is a JavaScript library for building user interfaces.
        </Accordion.Content>
      </Accordion.Item>
      
      <Accordion.Item value="2">
        <Accordion.Trigger>What are hooks?</Accordion.Trigger>
        <Accordion.Content>
          Hooks are functions that let you use state and lifecycle features.
        </Accordion.Content>
      </Accordion.Item>
    </Accordion>
  );
}

🏢 Real-World Usage

Radix UI

import * as Dialog from '@radix-ui/react-dialog';

<Dialog.Root>
  <Dialog.Trigger>Open</Dialog.Trigger>
  <Dialog.Portal>
    <Dialog.Overlay />
    <Dialog.Content>
      <Dialog.Title>Title</Dialog.Title>
      <Dialog.Description>Description</Dialog.Description>
      <Dialog.Close>Close</Dialog.Close>
    </Dialog.Content>
  </Dialog.Portal>
</Dialog.Root>

Headless UI

import { Menu } from '@headlessui/react';

<Menu>
  <Menu.Button>Options</Menu.Button>
  <Menu.Items>
    <Menu.Item>{({ active }) => (
      <a className={active ? 'active' : ''}>Settings</a>
    )}</Menu.Item>
  </Menu.Items>
</Menu>

📚 Key Takeaways

  1. Flexibility - Users compose exactly what they need
  2. Discoverability - IDE autocomplete shows all parts
  3. Type Safety - TypeScript validates structure
  4. Separation of Concerns - Each component has one job
  5. Customization - Easy to style and extend
  6. Context is key - Share state implicitly
  7. Namespace - Attach children to parent (Select.Item)

Use compound components when building reusable UI libraries or complex components that need high flexibility.

On this page