Reusable Component Patterns for Web Apps
Compound components, render props, headless hooks, and polymorphic patterns for flexible UI libraries.
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
Example
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.