Compound components pattern for flexible APIs

Maya Patel Jan 2026
2 tabs
import React, { createContext, useContext, useState, ReactNode } from 'react'

interface TabsContextType {
  activeTab: string
  setActiveTab: (tab: string) => void
}

const TabsContext = createContext<TabsContextType | undefined>(undefined)

function useTabsContext() {
  const context = useContext(TabsContext)
  if (!context) {
    throw new Error('Tabs components must be used within Tabs')
  }
  return context
}

interface TabsProps {
  defaultValue: string
  children: ReactNode
}

function TabsRoot({ defaultValue, children }: TabsProps) {
  const [activeTab, setActiveTab] = useState(defaultValue)

  return (
    <TabsContext.Provider value={{ activeTab, setActiveTab }}>
      <div className="tabs">{children}</div>
    </TabsContext.Provider>
  )
}

interface TabsListProps {
  children: ReactNode
}

function TabsList({ children }: TabsListProps) {
  return <div className="flex border-b">{children}</div>
}

interface TabsTriggerProps {
  value: string
  children: ReactNode
}

function TabsTrigger({ value, children }: TabsTriggerProps) {
  const { activeTab, setActiveTab } = useTabsContext()
  const isActive = activeTab === value

  return (
    <button
      onClick={() => setActiveTab(value)}
      className={`px-4 py-2 font-medium transition-colors ${
        isActive
          ? 'border-b-2 border-blue-600 text-blue-600'
          : 'text-gray-600 hover:text-gray-900'
      }`}
    >
      {children}
    </button>
  )
}

interface TabsContentProps {
  value: string
  children: ReactNode
}

function TabsContent({ value, children }: TabsContentProps) {
  const { activeTab } = useTabsContext()

  if (activeTab !== value) return null

  return <div className="py-4">{children}</div>
}

export const Tabs = Object.assign(TabsRoot, {
  List: TabsList,
  Trigger: TabsTrigger,
  Content: TabsContent,
})
2 files · typescript Explain with highlit

Compound components create flexible, composable APIs by sharing state between parent and child components via context. Instead of passing dozens of props, child components access shared state through context. A Select component might have Select.Trigger, Select.Options, and Select.Option subcomponents that work together. This pattern provides flexibility—consumers can rearrange, style, or extend components—while maintaining encapsulation. I use TypeScript's namespace syntax or dot notation to attach subcomponents. The parent component provides context, and children consume it with useContext. This is the pattern used by libraries like Radix UI and Headless UI for building accessible, customizable component primitives.