Designing React Components That Survive Real-World Complexity
How to architect React components that remain maintainable as requirements evolve, using composition patterns, proper abstractions, and thoughtful API design.
React components start simple. Then requirements change. Features accumulate. What began as a clean Button component becomes a 500-line monstrosity with fifteen props, conditional rendering everywhere, and logic that nobody dares to touch.
This article explores patterns for designing React components that survive real-world complexity—components that remain maintainable, testable, and extensible as your application grows.
The Problem with Prop-Driven Development
Most component APIs evolve through prop accumulation:
// What starts as this...
interface ButtonProps {
children: ReactNode;
onClick: () => void;
}
// ...becomes this
interface ButtonProps {
children: ReactNode;
onClick: () => void;
variant: 'primary' | 'secondary' | 'ghost' | 'destructive';
size: 'sm' | 'md' | 'lg' | 'xl';
loading: boolean;
disabled: boolean;
fullWidth: boolean;
leftIcon: ReactNode;
rightIcon: ReactNode;
ariaLabel: string;
testId: string;
className: string;
style: CSSProperties;
// ...and 20 more props
}
This approach has fundamental problems:
- Combinatorial explosion — Each new prop multiplies the possible states
- Implicit coupling — Props often depend on each other in undocumented ways
- API surface bloat — Consumers must understand all props to use the component correctly
- Testing burden — Every prop combination is a potential test case
Composition Over Configuration
The alternative is composition-based APIs. Instead of configuring a single component through props, you compose smaller, focused components together.
Before: Configuration Hell
<Card
title="User Profile"
description="Manage your account settings"
image="/avatar.png"
imagePosition="left"
variant="elevated"
padding="lg"
borderRadius="xl"
shadow="md"
hoverable
onClick={() => navigate('/profile')}
/>
After: Composable Primitives
<Card.Root className="hover:shadow-lg transition-shadow cursor-pointer" onClick={() => navigate('/profile')}>
<Card.Image src="/avatar.png" alt="User avatar" className="w-24 h-24 rounded-full" />
<Card.Content className="space-y-2">
<Card.Title>User Profile</Card.Title>
<Card.Description>Manage your account settings</Card.Description>
</Card.Content>
<Card.Action>
<ArrowRight className="h-4 w-4" />
</Card.Action>
</Card.Root>
The composable version:
- Explicit structure — You can see the component hierarchy
- Flexible — Swap, reorder, or omit any part
- Type-safe — Each sub-component has its own focused props
- Extensible — Add new parts without breaking existing code
Compound Components Pattern
The Compound Components pattern lets you create expressive APIs while keeping internal state management encapsulated.
// Card.tsx
interface CardContextValue {
variant: 'default' | 'elevated' | 'outlined';
}
const CardContext = createContext<CardContextValue | null>(null);
function CardRoot({
variant = 'default',
children,
...props
}: { variant?: CardContextValue['variant']; children: ReactNode } & HTMLDivElementProps) {
return (
<CardContext.Provider value={{ variant }}>
<div className={cn('card', variants[variant])} {...props}>
{children}
</div>
</CardContext.Provider>
);
}
function CardHeader({ children, ...props }: HTMLDivElementProps) {
return <div className="px-6 py-4 border-b" {...props}>{children}</div>;
}
function CardTitle({ children, ...props }: HTMLHeadingElementProps) {
return <h3 className="text-lg font-semibold" {...props}>{children}</h3>;
}
// Export as compound component
CardRoot.Header = CardHeader;
CardRoot.Title = CardTitle;
CardRoot.Content = CardContent;
CardRoot.Footer = CardFooter;
CardRoot.Action = CardAction;
export { CardRoot as Card };
Usage becomes declarative:
<Card variant="elevated">
<Card.Header>
<Card.Title>Settings</Card.Title>
</Card.Header>
<Card.Content>
<Form>...</Form>
</Card.Content>
<Card.Footer>
<Button>Save</Button>
</Card.Footer>
</Card>
Render Props for Complex Logic
When components need to share complex state or behavior, render props (or the modern equivalent: component injection) provide flexibility without prop explosion.
// DataTable.tsx
interface DataTableProps<T> {
data: T[];
columns: ColumnDef<T>[];
children: (props: {
rows: T[];
sort: (column: string) => void;
filter: (query: string) => void;
selection: Set<string>;
toggleRow: (id: string) => void;
}) => ReactNode;
}
function DataTable<T>({ data, columns, children }: DataTableProps<T>) {
const [sortConfig, setSortConfig] = useState<SortConfig>({ key: '', direction: 'asc' });
const [filter, setFilter] = useState('');
const [selection, setSelection] = useState<Set<string>>(new Set());
const processedData = useMemo(() =>
data.filter(/* filter logic */).sort(/* sort logic */),
[data, filter, sortConfig]
);
return (
<div className="data-table">
<DataTableToolbar
filter={filter}
onFilterChange={setFilter}
selectionCount={selection.size}
/>
{children({
rows: processedData,
sort: (key) => setSortConfig({ key, direction: 'asc' }),
filter: setFilter,
selection,
toggleRow: (id) => setSelection(prev => {
const next = new Set(prev);
next.has(id) ? next.delete(id) : next.add(id);
return next;
}),
})}
</div>
);
}
Consumers get full control over rendering:
<DataTable data={users} columns={userColumns}>
{({ rows, sort, filter, selection, toggleRow }) => (
<>
<Table>
<TableHeader>
{columns.map(col => (
<TableColumn
key={col.key}
onClick={() => sort(col.key)}
selected={selection.has(col.key)}
>
{col.header}
</TableColumn>
))}
</TableHeader>
<TableBody>
{rows.map(row => (
<TableRow
key={row.id}
selected={selection.has(row.id)}
onClick={() => toggleRow(row.id)}
>
{columns.map(col => (
<TableCell key={col.key}>{col.render(row)}</TableCell>
))}
</TableRow>
))}
</TableBody>
</Table>
<Pagination data={rows} />
</>
)}
</DataTable>
Slot Pattern for Layout Components
For layout components, the Slot Pattern (popularized by Radix UI) provides maximum flexibility:
// Dialog.tsx
const DialogRoot = ({ children, open, onOpenChange }: DialogRootProps) => {
return (
<DialogPrimitive.Root open={open} onOpenChange={onOpenChange}>
{children}
<DialogPrimitive.Portal>
<DialogPrimitive.Overlay className="fixed inset-0 bg-black/50" />
<DialogPrimitive.Content className="fixed top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2">
<Slot name="content" />
</DialogPrimitive.Content>
</DialogPrimitive.Portal>
</DialogPrimitive.Root>
);
};
const DialogTrigger = DialogPrimitive.Trigger;
const DialogClose = DialogPrimitive.Close;
// Slot component - renders children in a named slot
function Slot({ name, children }: { name: string; children?: ReactNode }) {
const slotRef = useRef<HTMLDivElement>(null);
// In a real implementation, this would use a context-based slot system
return <div ref={slotRef}>{children}</div>;
}
// Usage
<DialogRoot open={isOpen} onOpenChange={setIsOpen}>
<DialogTrigger asChild>
<Button>Open Dialog</Button>
</DialogTrigger>
<Slot name="content">
<DialogContent>
<DialogHeader>
<DialogTitle>Confirm Action</DialogTitle>
<DialogDescription>Are you sure?</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button variant="ghost" onClick={() => setIsOpen(false)}>Cancel</Button>
<Button variant="destructive" onClick={handleConfirm}>Confirm</Button>
</DialogFooter>
</DialogContent>
</Slot>
</DialogRoot>
TypeScript for Component APIs
Strong typing prevents misuse and serves as documentation:
// Discriminated unions for variant-based props
type ButtonProps =
| { variant: 'primary'; onClick: () => void; loading?: boolean }
| { variant: 'secondary'; onClick: () => void; disabled?: boolean }
| { variant: 'link'; href: string; external?: boolean };
// This ensures you can't pass `href` to a primary button
// or `onClick` to a link variant
// Branded types for IDs
type UserId = string & { readonly __brand: unique symbol };
type PostId = string & { readonly __brand: unique symbol };
function UserProfile({ userId }: { userId: UserId }) { /* ... */ }
// Template literal types for CSS-in-JS
type Spacing = `${number}${'px' | 'rem' | 'em'}`;
type Color = `#${string}`;
// Constraint props to valid combinations
interface FlexProps {
direction: 'row' | 'col';
align: 'start' | 'center' | 'end' | 'stretch';
justify: 'start' | 'center' | 'end' | 'between' | 'around';
gap: Spacing;
wrap?: boolean;
}
Testing Strategy for Composable Components
Composable components are easier to test because each piece has a single responsibility:
// Card.test.tsx
describe('Card', () => {
describe('Card.Root', () => {
it('renders with correct variant classes', () => {
render(<Card variant="elevated">Content</Card>);
expect(screen.getByText('Content').parentElement).toHaveClass('shadow-lg');
});
});
describe('Card.Header', () => {
it('renders children in header section', () => {
render(
<Card>
<Card.Header><span>Header Content</span></Card.Header>
</Card>
);
expect(screen.getByText('Header Content')).toBeInTheDocument();
});
});
describe('Composition', () => {
it('allows flexible composition', () => {
render(
<Card>
<Card.Content>Only content</Card.Content>
</Card>
);
expect(screen.getByText('Only content')).toBeInTheDocument();
expect(screen.queryByText('Header Content')).not.toBeInTheDocument();
});
});
});
Migration Strategy
You don't need to rewrite everything at once. Migrate incrementally:
- Identify pain points — Components with >7 props or complex conditional logic
- Extract sub-components — Pull out logical pieces (Header, Footer, Action)
- Add compound exports — Export sub-components as properties of the main component
- Deprecate old props — Mark props as deprecated, guide users to new API
- Remove deprecated props — After migration period
// During migration, support both APIs
interface CardProps {
// Legacy props (deprecated)
title?: string;
description?: string;
image?: string;
// New composition API
children?: ReactNode;
}
function Card({ title, description, image, children }: CardProps) {
if (children) {
// New composition API
return <div className="card">{children}</div>;
}
// Legacy API - warn in development
if (process.env.NODE_ENV !== 'production') {
console.warn('Card: Legacy props are deprecated. Use composition API.');
}
return (
<div className="card">
{image && <img src={image} alt="" />}
{title && <h3>{title}</h3>}
{description && <p>{description}</p>}
</div>
);
}
Key Takeaways
| Principle | Application | |-----------|-------------| | Composition over configuration | Build components as composable primitives | | Explicit over implicit | Make component structure visible in JSX | | Single responsibility | Each sub-component does one thing well | | Type-driven design | Use TypeScript to enforce valid combinations | | Progressive disclosure | Simple things simple, complex things possible |
The goal isn't to avoid props entirely—it's to use props for configuration and composition for structure. When a component's JSX structure becomes more expressive than its prop list, you've found the right balance.
Want to dive deeper? Check out my article on System Design for Real-World Applications or explore my React component library on GitHub.