DEV Community

Tarun Moorjani
Tarun Moorjani

Posted on

Compound Components in TypeScript: The Pattern That Scales

I used to build components like this:

<Select
  options={options}
  value={value}
  onChange={onChange}
  renderOption={(option) => <div>{option.label}</div>}
  renderTrigger={(selected) => <button>{selected?.label}</button>}
  placeholder="Select an option"
  disabled={false}
  error={error}
  // ... 20 more props
/>
Enter fullscreen mode Exit fullscreen mode

Every new feature meant another prop. Every customization request meant more render functions. The component became a configuration nightmare with 30+ props, and it still couldn't handle all the use cases.

Then I discovered how Radix UI and Headless UI build their components. They use a pattern called compound components, and it changed how I think about component APIs.

<Select value={value} onChange={onChange}>
  <Select.Trigger>
    <Select.Value placeholder="Select an option" />
    <Select.Icon />
  </Select.Trigger>

  <Select.Content>
    {options.map(option => (
      <Select.Item key={option.id} value={option.id}>
        <Select.ItemText>{option.label}</Select.ItemText>
        <Select.ItemIndicator />
      </Select.Item>
    ))}
  </Select.Content>
</Select>
Enter fullscreen mode Exit fullscreen mode

This isn't just prettier syntax. It's a fundamentally different approach to component design that scales infinitely while maintaining perfect type safety.

Let me show you how to build it.

What Are Compound Components?

Compound components are a set of components that work together to form a complete UI element. They share implicit state without prop drilling, and they compose naturally.

Think of HTML elements:

<select>
  <option value="1">Option 1</option>
  <option value="2">Option 2</option>
</select>
Enter fullscreen mode Exit fullscreen mode

The <option> elements don't receive a selected prop. They implicitly know about the <select> parent's state. Compound components bring this pattern to React with full type safety.

Pattern 1: Basic Compound Components with Context

Let's build an Accordion component from scratch.

Step 1: Define the Types

// Accordion.types.ts
import { ReactNode } from 'react';

export interface AccordionContextValue {
  openItems: Set<string>;
  toggleItem: (id: string) => void;
  isOpen: (id: string) => boolean;
}

export interface AccordionProps {
  children: ReactNode;
  defaultOpen?: string[];
  multiple?: boolean;
  onChange?: (openItems: string[]) => void;
}

export interface AccordionItemProps {
  children: ReactNode;
  id: string;
}

export interface AccordionTriggerProps {
  children: ReactNode;
}

export interface AccordionContentProps {
  children: ReactNode;
}
Enter fullscreen mode Exit fullscreen mode

Step 2: Create the Context

// Accordion.context.ts
import { createContext, useContext } from 'react';
import { AccordionContextValue } from './Accordion.types';

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

export function useAccordionContext() {
  const context = useContext(AccordionContext);

  if (!context) {
    throw new Error(
      'Accordion compound components must be used within <Accordion>'
    );
  }

  return context;
}

export const AccordionProvider = AccordionContext.Provider;
Enter fullscreen mode Exit fullscreen mode

Step 3: Build the Root Component

// Accordion.tsx
import { useState, useCallback } from 'react';
import { AccordionProvider } from './Accordion.context';
import { AccordionProps } from './Accordion.types';

export function Accordion({ 
  children, 
  defaultOpen = [],
  multiple = false,
  onChange 
}: AccordionProps) {
  const [openItems, setOpenItems] = useState<Set<string>>(
    new Set(defaultOpen)
  );

  const toggleItem = useCallback((id: string) => {
    setOpenItems(prev => {
      const next = new Set(prev);

      if (next.has(id)) {
        next.delete(id);
      } else {
        if (!multiple) {
          next.clear();
        }
        next.add(id);
      }

      onChange?.(Array.from(next));
      return next;
    });
  }, [multiple, onChange]);

  const isOpen = useCallback((id: string) => {
    return openItems.has(id);
  }, [openItems]);

  const contextValue = {
    openItems,
    toggleItem,
    isOpen,
  };

  return (
    <AccordionProvider value={contextValue}>
      <div className="accordion">
        {children}
      </div>
    </AccordionProvider>
  );
}
Enter fullscreen mode Exit fullscreen mode

Step 4: Build Child Components

// AccordionItem.tsx
import { createContext, useContext, ReactNode } from 'react';

interface AccordionItemContextValue {
  id: string;
  isOpen: boolean;
}

const AccordionItemContext = createContext<AccordionItemContextValue | null>(null);

export function useAccordionItemContext() {
  const context = useContext(AccordionItemContext);

  if (!context) {
    throw new Error(
      'AccordionItem.* components must be used within <Accordion.Item>'
    );
  }

  return context;
}

interface AccordionItemProps {
  children: ReactNode;
  id: string;
}

export function AccordionItem({ children, id }: AccordionItemProps) {
  const { isOpen } = useAccordionContext();
  const itemIsOpen = isOpen(id);

  const contextValue = {
    id,
    isOpen: itemIsOpen,
  };

  return (
    <AccordionItemContext.Provider value={contextValue}>
      <div className="accordion-item" data-state={itemIsOpen ? 'open' : 'closed'}>
        {children}
      </div>
    </AccordionItemContext.Provider>
  );
}

// AccordionTrigger.tsx
export function AccordionTrigger({ children }: { children: ReactNode }) {
  const { toggleItem } = useAccordionContext();
  const { id, isOpen } = useAccordionItemContext();

  return (
    <button
      className="accordion-trigger"
      onClick={() => toggleItem(id)}
      aria-expanded={isOpen}
    >
      {children}
    </button>
  );
}

// AccordionContent.tsx
export function AccordionContent({ children }: { children: ReactNode }) {
  const { isOpen } = useAccordionItemContext();

  if (!isOpen) return null;

  return (
    <div className="accordion-content">
      {children}
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

Step 5: Export with Dot Notation

// index.ts
import { Accordion as AccordionRoot } from './Accordion';
import { AccordionItem } from './AccordionItem';
import { AccordionTrigger } from './AccordionTrigger';
import { AccordionContent } from './AccordionContent';

export const Accordion = Object.assign(AccordionRoot, {
  Item: AccordionItem,
  Trigger: AccordionTrigger,
  Content: AccordionContent,
});

// Usage - beautiful, composable API
function MyAccordion() {
  return (
    <Accordion defaultOpen={['item-1']}>
      <Accordion.Item id="item-1">
        <Accordion.Trigger>What is TypeScript?</Accordion.Trigger>
        <Accordion.Content>
          TypeScript is a typed superset of JavaScript.
        </Accordion.Content>
      </Accordion.Item>

      <Accordion.Item id="item-2">
        <Accordion.Trigger>Why use compound components?</Accordion.Trigger>
        <Accordion.Content>
          They provide flexible, composable APIs with implicit state sharing.
        </Accordion.Content>
      </Accordion.Item>
    </Accordion>
  );
}
Enter fullscreen mode Exit fullscreen mode

Pattern 2: Type-Safe Dot Notation

Make TypeScript understand your compound component API.

// Proper typing for dot notation exports
type AccordionComponent = typeof AccordionRoot & {
  Item: typeof AccordionItem;
  Trigger: typeof AccordionTrigger;
  Content: typeof AccordionContent;
};

export const Accordion = Object.assign(AccordionRoot, {
  Item: AccordionItem,
  Trigger: AccordionTrigger,
  Content: AccordionContent,
}) as AccordionComponent;

// Now TypeScript knows:
// - Accordion is a component
// - Accordion.Item is a component
// - Accordion.Trigger is a component
// - etc.
Enter fullscreen mode Exit fullscreen mode

Alternative: Namespace Approach

// Accordion.tsx
export function Accordion({ children }: AccordionProps) {
  // ... implementation
}

export namespace Accordion {
  export const Item = AccordionItem;
  export const Trigger = AccordionTrigger;
  export const Content = AccordionContent;
}

// Usage is identical
<Accordion>
  <Accordion.Item id="1">
    <Accordion.Trigger>Question</Accordion.Trigger>
    <Accordion.Content>Answer</Accordion.Content>
  </Accordion.Item>
</Accordion>
Enter fullscreen mode Exit fullscreen mode

Pattern 3: Polymorphic Compound Components

Allow components to render as different elements.

// types/polymorphic.ts
import { ComponentPropsWithoutRef, ElementType, ReactNode } from 'react';

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

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

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

export type PolymorphicRef<C extends ElementType> =
  ComponentPropsWithoutRef<C>['ref'];
Enter fullscreen mode Exit fullscreen mode

Building a Polymorphic Trigger

// AccordionTrigger.tsx
import { forwardRef, ElementType } from 'react';
import {
  PolymorphicComponentProp,
  PolymorphicRef,
} from './types/polymorphic';

type AccordionTriggerProps<C extends ElementType = 'button'> =
  PolymorphicComponentProp<
    C,
    {
      children: ReactNode;
    }
  >;

export const AccordionTrigger = forwardRef(
  <C extends ElementType = 'button'>(
    { as, children, ...props }: AccordionTriggerProps<C>,
    ref?: PolymorphicRef<C>
  ) => {
    const Component = as || 'button';
    const { toggleItem } = useAccordionContext();
    const { id, isOpen } = useAccordionItemContext();

    return (
      <Component
        {...props}
        ref={ref}
        onClick={() => toggleItem(id)}
        aria-expanded={isOpen}
      >
        {children}
      </Component>
    );
  }
);

// Usage - fully typed!
<Accordion.Trigger>Default button</Accordion.Trigger>
<Accordion.Trigger as="div" role="button">Custom div</Accordion.Trigger>
<Accordion.Trigger as="a" href="#section">Link trigger</Accordion.Trigger>

// TypeScript knows which props are available:
<Accordion.Trigger 
  as="button" 
  type="button"  // ✅ Valid - button has type prop
  disabled       // ✅ Valid - button has disabled prop
/>

<Accordion.Trigger 
  as="div" 
  type="button"  // ❌ Error - div doesn't have type prop
  tabIndex={0}   // ✅ Valid - div has tabIndex
/>
Enter fullscreen mode Exit fullscreen mode

Pattern 4: Generic Compound Components

Build components that work with any data type.

// Select component that works with any data
interface SelectProps<T> {
  value: T | null;
  onChange: (value: T) => void;
  children: ReactNode;
}

interface SelectContextValue<T> {
  value: T | null;
  onChange: (value: T) => void;
  isOpen: boolean;
  setIsOpen: (open: boolean) => void;
}

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

function useSelectContext<T>() {
  const context = useContext(SelectContext) as SelectContextValue<T> | null;

  if (!context) {
    throw new Error('Select components must be used within <Select>');
  }

  return context;
}

export function Select<T>({ value, onChange, children }: SelectProps<T>) {
  const [isOpen, setIsOpen] = useState(false);

  const contextValue: SelectContextValue<T> = {
    value,
    onChange,
    isOpen,
    setIsOpen,
  };

  return (
    <SelectContext.Provider value={contextValue}>
      <div className="select">{children}</div>
    </SelectContext.Provider>
  );
}

// Select.Item is generic too
interface SelectItemProps<T> {
  value: T;
  children: ReactNode;
}

function SelectItem<T>({ value, children }: SelectItemProps<T>) {
  const { value: selectedValue, onChange, setIsOpen } = useSelectContext<T>();
  const isSelected = selectedValue === value;

  return (
    <button
      className="select-item"
      data-selected={isSelected}
      onClick={() => {
        onChange(value);
        setIsOpen(false);
      }}
    >
      {children}
    </button>
  );
}

Select.Item = SelectItem;

// Usage - full type inference!
interface User {
  id: string;
  name: string;
}

const users: User[] = [
  { id: '1', name: 'Alice' },
  { id: '2', name: 'Bob' },
];

<Select value={selectedUser} onChange={setSelectedUser}>
  <Select.Trigger>
    {selectedUser?.name ?? 'Select user'}
  </Select.Trigger>
  <Select.Content>
    {users.map(user => (
      <Select.Item key={user.id} value={user}>
        {/* TypeScript knows user is User */}
        {user.name}
      </Select.Item>
    ))}
  </Select.Content>
</Select>
Enter fullscreen mode Exit fullscreen mode

Pattern 5: Enforcing Component Structure

Prevent invalid component nesting.

// Tabs component that validates structure
interface TabsContextValue {
  activeTab: string;
  setActiveTab: (id: string) => void;
  tabs: Set<string>;
  registerTab: (id: string) => void;
  unregisterTab: (id: string) => void;
}

export function Tabs({ children, defaultTab }: TabsProps) {
  const [activeTab, setActiveTab] = useState(defaultTab);
  const [tabs, setTabs] = useState<Set<string>>(new Set());

  const registerTab = useCallback((id: string) => {
    setTabs(prev => new Set(prev).add(id));
  }, []);

  const unregisterTab = useCallback((id: string) => {
    setTabs(prev => {
      const next = new Set(prev);
      next.delete(id);
      return next;
    });
  }, []);

  const contextValue = {
    activeTab,
    setActiveTab,
    tabs,
    registerTab,
    unregisterTab,
  };

  return (
    <TabsContext.Provider value={contextValue}>
      {children}
    </TabsContext.Provider>
  );
}

// TabPanel auto-registers
export function TabPanel({ id, children }: TabPanelProps) {
  const { registerTab, unregisterTab, activeTab } = useTabsContext();

  useEffect(() => {
    registerTab(id);
    return () => unregisterTab(id);
  }, [id, registerTab, unregisterTab]);

  if (activeTab !== id) return null;

  return <div className="tab-panel">{children}</div>;
}

// TabTrigger validates tab exists
export function TabTrigger({ id, children }: TabTriggerProps) {
  const { setActiveTab, activeTab, tabs } = useTabsContext();

  // Warning if tab doesn't exist
  useEffect(() => {
    if (!tabs.has(id)) {
      console.warn(`Tab "${id}" doesn't have a corresponding TabPanel`);
    }
  }, [id, tabs]);

  return (
    <button
      className="tab-trigger"
      data-active={activeTab === id}
      onClick={() => setActiveTab(id)}
    >
      {children}
    </button>
  );
}
Enter fullscreen mode Exit fullscreen mode

Pattern 6: Render Props with Compound Components

Combine patterns for maximum flexibility.

// Accordion with render props
interface AccordionItemRenderProps {
  isOpen: boolean;
  toggle: () => void;
}

interface AccordionItemProps {
  id: string;
  children: (props: AccordionItemRenderProps) => ReactNode;
}

export function AccordionItem({ id, children }: AccordionItemProps) {
  const { isOpen, toggleItem } = useAccordionContext();
  const itemIsOpen = isOpen(id);

  return (
    <div className="accordion-item">
      {children({
        isOpen: itemIsOpen,
        toggle: () => toggleItem(id),
      })}
    </div>
  );
}

// Usage - full control over rendering
<Accordion>
  <Accordion.Item id="item-1">
    {({ isOpen, toggle }) => (
      <>
        <button onClick={toggle}>
          Question {isOpen ? '' : ''}
        </button>
        {isOpen && <div>Answer</div>}
      </>
    )}
  </Accordion.Item>
</Accordion>
Enter fullscreen mode Exit fullscreen mode

Pattern 7: Controlled vs Uncontrolled

Support both patterns with TypeScript.

// Union type for controlled vs uncontrolled
type ControlledProps = {
  value: string;
  onChange: (value: string) => void;
  defaultValue?: never;
};

type UncontrolledProps = {
  value?: never;
  onChange?: never;
  defaultValue: string;
};

type SelectProps = (ControlledProps | UncontrolledProps) & {
  children: ReactNode;
};

export function Select(props: SelectProps) {
  const isControlled = props.value !== undefined;

  const [internalValue, setInternalValue] = useState(
    props.defaultValue || ''
  );

  const value = isControlled ? props.value : internalValue;

  const onChange = isControlled
    ? props.onChange
    : setInternalValue;

  // Rest of implementation uses value and onChange
  // ...
}

// TypeScript enforces correct usage:

// ✅ Controlled
<Select value={value} onChange={setValue}>...</Select>

// ✅ Uncontrolled
<Select defaultValue="option1">...</Select>

// ❌ Mixed (TypeScript error)
<Select value={value} defaultValue="option1">...</Select>

// ❌ Missing onChange when controlled
<Select value={value}>...</Select>
Enter fullscreen mode Exit fullscreen mode

Pattern 8: Forwarding Refs Through Compound Components

// AccordionTrigger with ref forwarding
export const AccordionTrigger = forwardRef<
  HTMLButtonElement,
  AccordionTriggerProps
>(({ children, ...props }, ref) => {
  const { toggleItem } = useAccordionContext();
  const { id, isOpen } = useAccordionItemContext();

  return (
    <button
      {...props}
      ref={ref}
      onClick={() => toggleItem(id)}
      aria-expanded={isOpen}
    >
      {children}
    </button>
  );
});

AccordionTrigger.displayName = 'AccordionTrigger';

// Usage with ref
function MyComponent() {
  const triggerRef = useRef<HTMLButtonElement>(null);

  useEffect(() => {
    triggerRef.current?.focus();
  }, []);

  return (
    <Accordion>
      <Accordion.Item id="1">
        <Accordion.Trigger ref={triggerRef}>
          Question
        </Accordion.Trigger>
        <Accordion.Content>Answer</Accordion.Content>
      </Accordion.Item>
    </Accordion>
  );
}
Enter fullscreen mode Exit fullscreen mode

Pattern 9: Slot-Based Composition

Alternative pattern using named slots.

// Card with slots
interface CardProps {
  children: ReactNode;
}

interface CardSlotContextValue {
  slots: {
    header?: ReactNode;
    content?: ReactNode;
    footer?: ReactNode;
  };
  registerSlot: (name: string, content: ReactNode) => void;
}

const CardSlotContext = createContext<CardSlotContextValue | null>(null);

export function Card({ children }: CardProps) {
  const [slots, setSlots] = useState<CardSlotContextValue['slots']>({});

  const registerSlot = useCallback((name: string, content: ReactNode) => {
    setSlots(prev => ({ ...prev, [name]: content }));
  }, []);

  return (
    <CardSlotContext.Provider value={{ slots, registerSlot }}>
      {/* Process children to register slots */}
      <div style={{ display: 'none' }}>{children}</div>

      {/* Render slots in specific order */}
      <div className="card">
        {slots.header && <div className="card-header">{slots.header}</div>}
        {slots.content && <div className="card-content">{slots.content}</div>}
        {slots.footer && <div className="card-footer">{slots.footer}</div>}
      </div>
    </CardSlotContext.Provider>
  );
}

function CardHeader({ children }: { children: ReactNode }) {
  const { registerSlot } = useContext(CardSlotContext)!;

  useEffect(() => {
    registerSlot('header', children);
  }, [children, registerSlot]);

  return null;
}

function CardContent({ children }: { children: ReactNode }) {
  const { registerSlot } = useContext(CardSlotContext)!;

  useEffect(() => {
    registerSlot('content', children);
  }, [children, registerSlot]);

  return null;
}

Card.Header = CardHeader;
Card.Content = CardContent;

// Usage - order doesn't matter!
<Card>
  <Card.Content>This renders second</Card.Content>
  <Card.Header>This renders first</Card.Header>
</Card>
Enter fullscreen mode Exit fullscreen mode

Pattern 10: Complex State Management

Use useReducer for complex compound component state.

// Dropdown with complex state
type DropdownState = {
  isOpen: boolean;
  selectedIndex: number;
  searchQuery: string;
  highlightedIndex: number;
};

type DropdownAction =
  | { type: 'OPEN' }
  | { type: 'CLOSE' }
  | { type: 'TOGGLE' }
  | { type: 'SELECT'; index: number }
  | { type: 'HIGHLIGHT'; index: number }
  | { type: 'SEARCH'; query: string }
  | { type: 'KEYBOARD_NAVIGATE'; direction: 'up' | 'down' }
  | { type: 'RESET' };

function dropdownReducer(
  state: DropdownState,
  action: DropdownAction
): DropdownState {
  switch (action.type) {
    case 'OPEN':
      return { ...state, isOpen: true };

    case 'CLOSE':
      return { ...state, isOpen: false, searchQuery: '' };

    case 'TOGGLE':
      return { ...state, isOpen: !state.isOpen };

    case 'SELECT':
      return {
        ...state,
        selectedIndex: action.index,
        isOpen: false,
        searchQuery: '',
      };

    case 'HIGHLIGHT':
      return { ...state, highlightedIndex: action.index };

    case 'SEARCH':
      return { ...state, searchQuery: action.query };

    case 'KEYBOARD_NAVIGATE':
      const direction = action.direction === 'up' ? -1 : 1;
      return {
        ...state,
        highlightedIndex: Math.max(
          0,
          Math.min(state.highlightedIndex + direction, /* itemCount - 1 */ 10)
        ),
      };

    case 'RESET':
      return {
        isOpen: false,
        selectedIndex: -1,
        searchQuery: '',
        highlightedIndex: -1,
      };

    default:
      return state;
  }
}

export function Dropdown({ children }: { children: ReactNode }) {
  const [state, dispatch] = useReducer(dropdownReducer, {
    isOpen: false,
    selectedIndex: -1,
    searchQuery: '',
    highlightedIndex: -1,
  });

  const contextValue = {
    state,
    dispatch,
  };

  return (
    <DropdownContext.Provider value={contextValue}>
      {children}
    </DropdownContext.Provider>
  );
}
Enter fullscreen mode Exit fullscreen mode

Real-World Example: Complete Select Component

// Select/index.tsx - Full implementation
import { useState, useRef, useEffect, ReactNode, forwardRef } from 'react';

// Types
interface SelectContextValue<T> {
  value: T | null;
  onChange: (value: T) => void;
  isOpen: boolean;
  setIsOpen: (open: boolean) => void;
  triggerRef: React.RefObject<HTMLButtonElement>;
}

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

function useSelectContext<T>() {
  const context = useContext(SelectContext);
  if (!context) {
    throw new Error('Select.* components must be used within <Select>');
  }
  return context as SelectContextValue<T>;
}

// Root Component
interface SelectProps<T> {
  value: T | null;
  onChange: (value: T) => void;
  children: ReactNode;
}

function SelectRoot<T>({ value, onChange, children }: SelectProps<T>) {
  const [isOpen, setIsOpen] = useState(false);
  const triggerRef = useRef<HTMLButtonElement>(null);

  // Close on outside click
  useEffect(() => {
    if (!isOpen) return;

    const handleClick = (e: MouseEvent) => {
      if (!triggerRef.current?.contains(e.target as Node)) {
        setIsOpen(false);
      }
    };

    document.addEventListener('click', handleClick);
    return () => document.removeEventListener('click', handleClick);
  }, [isOpen]);

  const contextValue: SelectContextValue<T> = {
    value,
    onChange,
    isOpen,
    setIsOpen,
    triggerRef,
  };

  return (
    <SelectContext.Provider value={contextValue}>
      <div className="select">{children}</div>
    </SelectContext.Provider>
  );
}

// Trigger Component
interface SelectTriggerProps {
  children: ReactNode;
}

const SelectTrigger = forwardRef<HTMLButtonElement, SelectTriggerProps>(
  ({ children }, ref) => {
    const { isOpen, setIsOpen, triggerRef } = useSelectContext();

    return (
      <button
        ref={(node) => {
          (triggerRef as React.MutableRefObject<HTMLButtonElement | null>).current = node;
          if (typeof ref === 'function') ref(node);
          else if (ref) ref.current = node;
        }}
        className="select-trigger"
        onClick={() => setIsOpen(!isOpen)}
        aria-expanded={isOpen}
      >
        {children}
      </button>
    );
  }
);

// Value Component
function SelectValue({ placeholder }: { placeholder?: string }) {
  const { value } = useSelectContext();

  if (value === null) {
    return <span className="select-placeholder">{placeholder}</span>;
  }

  // User provides the display via children in SelectItem
  return null;
}

// Content Component
function SelectContent({ children }: { children: ReactNode }) {
  const { isOpen } = useSelectContext();

  if (!isOpen) return null;

  return (
    <div className="select-content">
      {children}
    </div>
  );
}

// Item Component
interface SelectItemProps<T> {
  value: T;
  children: ReactNode;
}

function SelectItem<T>({ value, children }: SelectItemProps<T>) {
  const { value: selectedValue, onChange, setIsOpen } = useSelectContext<T>();
  const isSelected = selectedValue === value;

  return (
    <button
      className="select-item"
      data-selected={isSelected}
      onClick={() => {
        onChange(value);
        setIsOpen(false);
      }}
    >
      {children}
      {isSelected && <span className="select-checkmark"></span>}
    </button>
  );
}

// Export with dot notation
export const Select = Object.assign(SelectRoot, {
  Trigger: SelectTrigger,
  Value: SelectValue,
  Content: SelectContent,
  Item: SelectItem,
});

// Usage
interface Option {
  id: string;
  label: string;
}

function App() {
  const [selected, setSelected] = useState<Option | null>(null);

  const options: Option[] = [
    { id: '1', label: 'Option 1' },
    { id: '2', label: 'Option 2' },
    { id: '3', label: 'Option 3' },
  ];

  return (
    <Select value={selected} onChange={setSelected}>
      <Select.Trigger>
        <Select.Value placeholder="Select an option" />
        {selected && <span>{selected.label}</span>}
      </Select.Trigger>

      <Select.Content>
        {options.map(option => (
          <Select.Item key={option.id} value={option}>
            {option.label}
          </Select.Item>
        ))}
      </Select.Content>
    </Select>
  );
}
Enter fullscreen mode Exit fullscreen mode

Advantages Over Alternative Patterns

vs. Monolithic Components

// ❌ Monolithic - inflexible
<Select
  options={options}
  renderOption={(opt) => <div>{opt.label}</div>}
  renderTrigger={(val) => <button>{val?.label}</button>}
  icon={<ChevronIcon />}
  searchable={true}
  clearable={true}
  // 20+ more props
/>

// ✅ Compound - composable
<Select value={value} onChange={onChange}>
  <Select.Trigger>
    {value?.label ?? 'Select'}
    <ChevronIcon />
  </Select.Trigger>
  <Select.Content>
    <Select.Search />
    {options.map(opt => (
      <Select.Item value={opt}>{opt.label}</Select.Item>
    ))}
  </Select.Content>
</Select>
Enter fullscreen mode Exit fullscreen mode

vs. Render Props

// ❌ Render props - nesting hell
<Select value={value} onChange={onChange}>
  {({ isOpen, selectedValue }) => (
    <>
      <SelectTrigger>
        {({ toggle }) => (
          <button onClick={toggle}>
            {selectedValue?.label ?? 'Select'}
          </button>
        )}
      </SelectTrigger>
      {isOpen && (
        <SelectContent>
          {({ selectOption }) => (
            options.map(opt => (
              <button onClick={() => selectOption(opt)}>
                {opt.label}
              </button>
            ))
          )}
        </SelectContent>
      )}
    </>
  )}
</Select>

// ✅ Compound - flat and readable
<Select value={value} onChange={onChange}>
  <Select.Trigger>
    <Select.Value />
  </Select.Trigger>
  <Select.Content>
    {options.map(opt => (
      <Select.Item value={opt}>{opt.label}</Select.Item>
    ))}
  </Select.Content>
</Select>
Enter fullscreen mode Exit fullscreen mode

Best Practices

1. Clear Component Names

// ✅ Good - obvious hierarchy
<Accordion>
  <Accordion.Item>
    <Accordion.Trigger />
    <Accordion.Content />
  </Accordion.Item>
</Accordion>

// ❌ Bad - unclear relationship
<Accordion>
  <Item>
    <Button />
    <Panel />
  </Item>
</Accordion>
Enter fullscreen mode Exit fullscreen mode

2. Throw Helpful Errors

function useAccordionContext() {
  const context = useContext(AccordionContext);

  if (!context) {
    throw new Error(
      'Accordion.* components must be used within <Accordion>. ' +
      'Did you forget to wrap your components?'
    );
  }

  return context;
}
Enter fullscreen mode Exit fullscreen mode

3. Document Component Structure

/**
 * Accordion component for collapsible content sections.
 * 
 * @example
 * ```

tsx
 * <Accordion defaultOpen={['item-1']}>
 *   <Accordion.Item id="item-1">
 *     <Accordion.Trigger>Question</Accordion.Trigger>
 *     <Accordion.Content>Answer</Accordion.Content>
 *   </Accordion.Item>
 * </Accordion>
 *

Enter fullscreen mode Exit fullscreen mode

/
export const Accordion = { /
... */ };




### 4. Support Composition



```typescript
// Allow custom components between layers
<Accordion>
  <div className="accordion-section">
    <h2>Section Title</h2>
    <Accordion.Item id="1">
      <Accordion.Trigger>Question</Accordion.Trigger>
      <Accordion.Content>Answer</Accordion.Content>
    </Accordion.Item>
  </div>
</Accordion>
Enter fullscreen mode Exit fullscreen mode

When to Use Compound Components

Use compound components when:

  • Components have complex internal state
  • Multiple child components need to share state
  • You want maximum customization flexibility
  • The component hierarchy is important
  • You're building a design system

Don't use compound components when:

  • A simple component with 3-5 props would work
  • There's no shared state between children
  • The API would be confusing for your use case
  • You're building a one-off component

Conclusion

Compound components transform component APIs from configuration nightmares into composable, flexible systems.

Instead of:

  • 30+ props
  • Complex render prop functions
  • Limited customization
  • Prop drilling

You get:

  • Implicit state sharing
  • Infinite customization
  • Clear component hierarchy
  • Perfect TypeScript support

The pattern takes more code upfront, but it scales infinitely. Every design system library uses it for a reason.

Start with a simple compound component. Build an Accordion or Tabs. Once you see how the pattern works, you'll wonder how you ever lived without it.


What components would benefit from the compound pattern in your codebase? Share your ideas!

Top comments (0)