I used to think good components meant lots of props. I’d end up with components that had 20+ props, half of them optional, and a README longer than the component itself.
Then I learned about composition. Now my components have fewer props, are more flexible, and actually make sense.
The Problem: Prop Drilling Hell
Here’s a Card component I wrote in 2023 (don’t judge me):
interface CardProps {
title: string;
subtitle?: string;
image?: string;
imageAlt?: string;
imagePosition?: 'top' | 'left' | 'right';
footer?: React.ReactNode;
footerAlign?: 'left' | 'center' | 'right';
onClick?: () => void;
variant?: 'default' | 'outlined' | 'elevated';
padding?: 'none' | 'small' | 'medium' | 'large';
// ... 10 more props
}
Every new feature meant a new prop. Want a badge in the corner? New prop. Want custom spacing? New prop. Want to sacrifice a goat to the moon? Probably a new prop.
This doesn’t scale. Trust me.
Pattern 1: Compound Components
This is my favorite pattern. Instead of one mega-component, you build small pieces that work together.
Before:
<Card
title="Hello"
subtitle="World"
footer={<button>Click me</button>}
footerAlign="right"
/>
After:
<Card>
<Card.Header>
<Card.Title>Hello</Card.Title>
<Card.Subtitle>World</Card.Subtitle>
</Card.Header>
<Card.Footer align="right">
<button>Click me</button>
</Card.Footer>
</Card>
More code? Yes. More flexible? Absolutely.
Here’s how you build it:
interface CardProps {
children: React.ReactNode;
className?: string;
}
function Card({ children, className }: CardProps) {
return (
<div className={`card ${className}`}>
{children}
</div>
);
}
Card.Header = function CardHeader({ children }: { children: React.ReactNode }) {
return <div className="card-header">{children}</div>;
};
Card.Title = function CardTitle({ children }: { children: React.ReactNode }) {
return <h3 className="card-title">{children}</h3>;
};
Card.Subtitle = function CardSubtitle({ children }: { children: React.ReactNode }) {
return <p className="card-subtitle">{children}</p>;
};
Card.Footer = function CardFooter({
children,
align = 'left'
}: {
children: React.ReactNode;
align?: 'left' | 'center' | 'right';
}) {
return (
<div className={`card-footer card-footer-${align}`}>
{children}
</div>
);
};
Now users can compose however they want:
// Just a title
<Card>
<Card.Title>Simple</Card.Title>
</Card>
// Title and custom content
<Card>
<Card.Header>
<Card.Title>Complex</Card.Title>
<Badge>New</Badge>
</Card.Header>
<p>Custom content here</p>
</Card>
// Whatever you want
<Card>
<img src="..." />
<Card.Title>Image card</Card.Title>
<Card.Footer>
<Button>Action</Button>
</Card.Footer>
</Card>
Zero new props needed. Infinite flexibility.
Pattern 2: Render Props
When you need to share logic but let users control the UI.
I use this in Pol-UI’s Dropdown component:
<Dropdown>
{({ isOpen, toggle }) => (
<>
<button onClick={toggle}>
Menu {isOpen ? '▲' : '▼'}
</button>
{isOpen && (
<div className="dropdown-menu">
<a href="/profile">Profile</a>
<a href="/settings">Settings</a>
</div>
)}
</>
)}
</Dropdown>
The Dropdown handles the state. You handle the UI. Clean separation.
Implementation:
interface DropdownProps {
children: (props: {
isOpen: boolean;
toggle: () => void;
close: () => void;
}) => React.ReactNode;
}
function Dropdown({ children }: DropdownProps) {
const [isOpen, setIsOpen] = useState(false);
const toggle = () => setIsOpen(!isOpen);
const close = () => setIsOpen(false);
return <>{children({ isOpen, toggle, close })}</>;
}
Simple, powerful, flexible.
Pattern 3: Slots (My Secret Weapon)
This is less common but super useful. Instead of children, you accept specific named slots.
interface ModalProps {
header?: React.ReactNode;
body: React.ReactNode;
footer?: React.ReactNode;
isOpen: boolean;
onClose: () => void;
}
function Modal({ header, body, footer, isOpen, onClose }: ModalProps) {
if (!isOpen) return null;
return (
<div className="modal-overlay" onClick={onClose}>
<div className="modal" onClick={(e) => e.stopPropagation()}>
{header && <div className="modal-header">{header}</div>}
<div className="modal-body">{body}</div>
{footer && <div className="modal-footer">{footer}</div>}
</div>
</div>
);
}
Usage:
<Modal
isOpen={isOpen}
onClose={() => setIsOpen(false)}
header={<h2>Delete Account</h2>}
body={<p>Are you sure? This cannot be undone.</p>}
footer={
<>
<Button onClick={() => setIsOpen(false)}>Cancel</Button>
<Button variant="danger" onClick={handleDelete}>Delete</Button>
</>
}
/>
Each slot is optional. Each slot can be anything. No weird prop names like footerContent or headerElement.
Pattern 4: Context for Deep Composition
When compound components need to share state, use Context.
Real example from Pol-UI’s Tabs:
const TabsContext = createContext<{
activeTab: string;
setActiveTab: (id: string) => void;
} | null>(null);
function Tabs({ children, defaultTab }: {
children: React.ReactNode;
defaultTab: string;
}) {
const [activeTab, setActiveTab] = useState(defaultTab);
return (
<TabsContext.Provider value={{ activeTab, setActiveTab }}>
<div className="tabs">{children}</div>
</TabsContext.Provider>
);
}
Tabs.List = function TabsList({ children }: { children: React.ReactNode }) {
return <div className="tabs-list">{children}</div>;
};
Tabs.Tab = function Tab({ id, children }: {
id: string;
children: React.ReactNode;
}) {
const context = useContext(TabsContext);
if (!context) throw new Error('Tab must be used within Tabs');
const isActive = context.activeTab === id;
return (
<button
className={`tab ${isActive ? 'active' : ''}`}
onClick={() => context.setActiveTab(id)}
>
{children}
</button>
);
};
Tabs.Panel = function TabPanel({ id, children }: {
id: string;
children: React.ReactNode;
}) {
const context = useContext(TabsContext);
if (!context) throw new Error('TabPanel must be used within Tabs');
if (context.activeTab !== id) return null;
return <div className="tab-panel">{children}</div>;
};
Usage is beautiful:
<Tabs defaultTab="profile">
<Tabs.List>
<Tabs.Tab id="profile">Profile</Tabs.Tab>
<Tabs.Tab id="settings">Settings</Tabs.Tab>
<Tabs.Tab id="billing">Billing</Tabs.Tab>
</Tabs.List>
<Tabs.Panel id="profile">
<ProfileForm />
</Tabs.Panel>
<Tabs.Panel id="settings">
<SettingsForm />
</Tabs.Panel>
<Tabs.Panel id="billing">
<BillingInfo />
</Tabs.Panel>
</Tabs>
No prop drilling. No manual state management. It just works.
When to Use Each Pattern
Compound Components: When you have a component with multiple related parts (Card, Tabs, Accordion)
Render Props: When you need to share logic but not UI (Dropdown, Toggle, Form validation)
Slots: When you have a fixed layout with customizable sections (Modal, Layout, Page)
Context: When compound components need to share state (Tabs, Accordion, Stepper)
The Real Benefit: Less Breaking Changes
Composition patterns make your API more stable.
With my old Card component, every new feature was a breaking change. New prop? Gotta update the types. Change a default? Might break someone’s UI.
With composition, I can add Card.Badge or Card.Image without touching existing code. Users opt-in to new features. Nothing breaks.
This matters a lot when you’re maintaining a library like Pol-UI.
Start Small
You don’t need to refactor everything. Start with one component. Maybe your Modal or Card. See how it feels.
I started with Tabs in Pol-UI. Then Accordion. Then Card. Now it’s my default approach.
Your components will thank you. Your users will thank you. Your future self will definitely thank you.
Now go compose something beautiful.
Related Articles
How to Build a UI Library: Lessons from Creating Pol-UI with React 🍬
Complete guide to building a React UI component library. Learn architecture decisions, component API design, TypeScript setup, testing strategies, and documentation best practices from real-world experience.
Modern CSS Features 2026: Container Queries, :has(), Cascade Layers & More 🎨
Complete guide to modern CSS features in 2026. Learn container queries, :has() selector, cascade layers, subgrid, OKLCH colors, and view transitions. Practical examples with browser support info.
React 19 New Features: Actions, use Hook, and Migration Guide 🎉
Complete React 19 guide covering new features: Actions API, use hook, document metadata, ref as prop, and more. Includes migration tips, breaking changes, and real-world examples from production apps.