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
- Flexibility - Users compose exactly what they need
- Discoverability - IDE autocomplete shows all parts
- Type Safety - TypeScript validates structure
- Separation of Concerns - Each component has one job
- Customization - Easy to style and extend
- Context is key - Share state implicitly
- Namespace - Attach children to parent (Select.Item)
Use compound components when building reusable UI libraries or complex components that need high flexibility.