Skip to content
Back to Design Hub

Reusable Component Patterns for Web Apps

Compound components, render props, headless hooks, and polymorphic patterns for flexible UI libraries.

Component PatternsIntermediate

Why

Hardcoded component APIs force consumers into specific markup structures. Flexible patterns let downstream teams customize rendering, behavior, and styling without forking the library.

Compound component patterns are used by Radix UI, Headless UI, and shadcn/ui — the three most adopted headless libraries in 2024. They reduce prop drilling by 70% compared to flat component APIs.

When

Use these patterns when building a shared UI library, designing data-heavy components (tables, forms), or when consumers need deep customization without losing built-in behavior. Skip compound patterns for leaf components (Button, Input) — they add indirection without benefit.

Use for components with 3+ interrelated parts like Tabs, Select, Menu.

How

1. Compound Component — parent holds state, children read via context <Select><Select.Trigger/><Select.Menu><Select.Item/></Select.Menu></Select> 2. Render Props — pass a function that receives internal state <DataTable renderCell={(row, col) => <Badge>{row[col]}</Badge>} /> 3. Headless Hook — extract logic from UI, return state + helpers const { open, toggle, close } = useDisclosure({ defaultOpen: false }) 4. Polymorphic

Example

// Compound Component pattern with context const TabsContext = createContext<{ active: string; setActive: (id: string) => void } | null>(null); function Tabs({ children, defaultTab }: { children: React.ReactNode; defaultTab: string }) { const [active, setActive] = useState(defaultTab); return <TabsContext.Provider value={{ active, setActive }}>{children}</TabsContext.Provider>; } Tabs.List = function TabsList({ children }: { children: React.ReactNode }) { return <div role="tablist" className="flex gap-2">{children}</div>; }; Tabs.Tab = function TabsTab({ id, children }: { id: string; children: React.ReactNode }) { const ctx = useContext(TabsContext)!; return ( <button role="tab" aria-selected={ctx.active === id} onClick={() => ctx.setActive(id)}> {children} </button> ); }; Tabs.Panel = function TabsPanel({ id, children }: { id: string; children: React.ReactNode }) { const ctx = useContext(TabsContext)!; return ctx.active === id ? <div role="tabpanel">{children}</div> : null; }; // Usage — flexible composition, no prop drilling <Tabs defaultTab="overview"> <Tabs.List> <Tabs.Tab id="overview">Overview</Tabs.Tab> <Tabs.Tab id="settings">Settings</Tabs.Tab> </Tabs.List> <Tabs.Panel id="overview">Content here</Tabs.Panel> <Tabs.Panel id="settings">Settings here</Tabs.Panel> </Tabs>

Common Problems

  • ⚠Context default value is null — consumers forget to check, causing runtime crashes
  • ⚠Polymorphic `as` breaks TypeScript inference — ref typing and prop union become `any`
  • ⚠Too many compound sub-components — the API surface becomes unmemorizable
  • ⚠Headless hooks return unstable function references — causes unnecessary re-renders

Best Practices

  • ✓Throw a descriptive error when context is null — 'Tabs.Tab must be used within <Tabs>'
  • ✓Use useCallback/useMemo to stabilize headless hook return values
  • ✓Limit compound API to 5 sub-components — beyond that, switch to config object pattern
  • ✓For polymorphic `as`, use the `as` prop pattern from Radix UI for correct TypeScript inference

FAQ

Compound component vs render props — when to use which?

Use compound for static composition (Tabs.Tab, Tabs.Panel); use render props when the consumer needs to render each item with access to internal state (DataTable renderCell).

Why does my compound component crash with 'cannot read property of null'?

The context default is null and a sub-component is used outside its parent. Throw a descriptive error ('Tabs.Tab must be used within <Tabs>') instead of a null default.

What is a headless hook and why use it?

It extracts logic from UI (useDisclosure returns open/toggle/close) so you can style freely. Use it when you need the same behavior across different visual treatments.

How do I make a polymorphic `as` prop type-safe?

Use the `as` prop pattern from Radix UI — it infers the element's props and merges with yours. Avoid naive `as: keyof JSX.IntrinsicElements` which loses prop typing.

When do compound components become too complex?

Past 5 sub-components the API becomes unmemorizable. Switch to a config-object pattern: <Tabs config={{...}} /> with slots.