Composition Over Inheritance — Build flexible UIs by composing components instead of extending them
Recipe
// Slot-based composition: pass components as props
function Layout({ header, sidebar, children }: {
header: React.ReactNode;
sidebar: React.ReactNode;
children: React.ReactNode;
}) {
return (
<div className="grid grid-cols-[240px_1fr] grid-rows-[64px_1fr]">
<header className="col-span-2">{header}</header>
<aside>{sidebar}</aside>
<main>{children}</main>
</div>
);
}
// Usage
<Layout
header={<TopNav user={currentUser} />}
sidebar={<SideMenu items={menuItems} />}
>
<Dashboard data={dashboardData} />
</Layout>When to reach for this: Whenever you need a reusable container, layout, or wrapper that should remain agnostic about its content. Prefer composition over inheritance in every case in React.
Working Example
import { type ReactNode } from "react";
// A Card component with composable slots
interface CardProps {
media?: ReactNode;
actions?: ReactNode;
children: ReactNode;
variant?: "elevated" | "outlined";
}
function Card({ media, actions, children, variant = "outlined" }: CardProps) {
const base = "rounded-lg overflow-hidden";
const styles = variant === "elevated"
? `${base} shadow-lg bg-white`
: `${base} border border-gray-200 bg-white`;
return (
<div className={styles}>
{media && <div className="aspect-video overflow-hidden">{media}</div>}
<div className="p-4">{children}</div>
{actions && (
<div className="px-4 pb-4 flex gap-2 justify-end">{actions}</div>
)}
</div>
);
}
// Specialization through composition, NOT inheritance
function ProductCard({ product }: { product: Product }) {
return (
<Card
variant="elevated"
media={<img src={product.image} alt={product.name} />}
actions={
<>
<button className="btn-secondary">Save</button>
<button className="btn-primary">Add to Cart</button>
</>
}
>
<h3 className="font-semibold">{product.name}</h3>
<p className="text-gray-600">${product.price}</p>
</Card>
);
}
interface Product {
name: string;
price: number;
image: string;
}What this demonstrates:
- Slot pattern via named
ReactNodeprops (media,actions,children) - Specialization by wrapping a generic component (
Card) inside a domain-specific one (ProductCard) - Zero inheritance —
ProductCardis not a subclass ofCard
Deep Dive
How It Works
- React components are functions that return JSX. There is no class hierarchy to extend.
childrenis the primary composition primitive — any JSX nested inside a component is passed asprops.children.- Named slots (props typed as
ReactNode) let you inject content into specific positions within a component's layout. - Specialization is achieved by creating a new component that renders a general component with specific props pre-filled.
- Containment is achieved by components that don't know their children ahead of time and use
{children}as a placeholder.
Parameters & Return Values
| Prop Pattern | Type | Purpose |
|---|---|---|
children | ReactNode | Default slot for nested content |
Named slot (e.g. header) | ReactNode | Explicit placement of content in a specific area |
| Render callback | (data: T) => ReactNode | Content that needs access to internal state (see render-props) |
| Component prop | React.ComponentType<P> | Inject a full component to be instantiated internally |
Variations
Component injection pattern — pass a component type rather than a rendered element:
interface ListProps<T> {
items: T[];
renderItem: React.ComponentType<{ item: T }>;
}
function List<T>({ items, renderItem: Item }: ListProps<T>) {
return (
<ul>
{items.map((item, i) => (
<li key={i}><Item item={item} /></li>
))}
</ul>
);
}Provider composition — flatten nested context providers:
function AppProviders({ children }: { children: ReactNode }) {
return (
<ThemeProvider>
<AuthProvider>
<QueryProvider>
{children}
</QueryProvider>
</AuthProvider>
</ThemeProvider>
);
}TypeScript Notes
- Use
ReactNodefor slots that accept any renderable content (strings, elements, fragments,null). - Use
React.ComponentType<P>when you need to pass a component that will be instantiated with specific props. - Use
ReactElementonly when you need to narrow to actual JSX elements (excluding strings and numbers). - Generic composition (
List<T>) preserves type safety through the component boundary.
Gotchas
-
Overusing
childrenfor multiple slots — When you pass everything aschildren, you lose control over placement. Fix: Use namedReactNodeprops for distinct content areas. -
Prop drilling through composed layers — Deep composition can lead to passing props through many levels. Fix: Use context for truly cross-cutting concerns, and keep composition shallow.
-
Breaking memoization with inline JSX slots — Passing
<Component />inline as a prop creates a new element reference each render. Fix: Lift static slot content outside the render or wrap withuseMemofor expensive trees. -
Confusing specialization with configuration — Creating a new component just to set a few props is fine; creating a wrapper that re-exposes all original props is a sign you need composition, not wrapping. Fix: Use the original component directly and pass props at the call site.
Alternatives
| Approach | Trade-off |
|---|---|
| Composition (slots) | Most flexible; requires more JSX at the call site |
| Configuration props (variant strings) | Less flexible but simpler API for common cases |
| Render props | More power when slot content needs internal state |
| Higher-order components | Adds behavior without changing API, but harder to debug |
| Inheritance | Not recommended in React — breaks with function components |
FAQs
What is the difference between composition and inheritance in React?
- React components are functions, not classes, so there is no class hierarchy to extend.
- Composition means building UIs by nesting and combining components instead of creating subclass relationships.
- Specialization is achieved by wrapping a general component with specific props pre-filled, not by extending it.
- React officially recommends composition over inheritance in every case.
When should you use named slot props instead of just children?
- Use named
ReactNodeprops (likeheader,sidebar,actions) when content needs to be placed in specific positions within a layout. - Use
childrenalone only when there is a single content area. - Named slots give you explicit control over placement that
childrenalone cannot provide.
How does the component injection pattern differ from passing rendered elements as props?
// Rendered element: you pass JSX
<Card media={<img src={url} alt="photo" />} />
// Component injection: you pass a component type
<List items={data} renderItem={ProductRow} />- With component injection, the parent component instantiates the injected component internally and controls what props it receives.
- With rendered elements, the consumer controls instantiation and prop values.
How do you flatten deeply nested context providers using composition?
function AppProviders({ children }: { children: ReactNode }) {
return (
<ThemeProvider>
<AuthProvider>
<QueryProvider>
{children}
</QueryProvider>
</AuthProvider>
</ThemeProvider>
);
}- Create a single wrapper component that nests all providers.
- Consumers only see
<AppProviders>at the call site.
What is the difference between ReactNode, ReactElement, and React.ComponentType<P> in TypeScript?
ReactNodeaccepts any renderable content: strings, numbers, elements, fragments, andnull.ReactElementnarrows to actual JSX elements only, excluding strings and numbers.React.ComponentType<P>is a component type (function or class) that can be instantiated with props of typeP.
How does generic composition like List<T> preserve type safety?
interface ListProps<T> {
items: T[];
renderItem: React.ComponentType<{ item: T }>;
}
function List<T>({ items, renderItem: Item }: ListProps<T>) {
return <ul>{items.map((item, i) => <li key={i}><Item item={item} /></li>)}</ul>;
}- TypeScript infers
Tfrom theitemsarray and enforces it onrenderItem. - The consumer gets full autocomplete on the
itemprop inside the render component.
Gotcha: Why does passing inline JSX as a slot prop break memoization?
- Passing
<Component />inline as a prop creates a new React element reference every render. - If the receiving component uses
React.memo, the new reference causes it to re-render. - Fix: lift static slot content outside the render function or wrap with
useMemo.
Gotcha: When does creating a wrapper component become an anti-pattern?
- If your wrapper re-exposes all original props without adding behavior, you have an unnecessary layer.
- This is configuration, not composition. Just use the original component directly and pass props at the call site.
- Wrappers are appropriate when they pre-fill specific props to create a domain-specific specialization.
Why is inheritance not recommended in React?
- React's component model is based on functions and composition, not class hierarchies.
- Function components cannot be extended via inheritance at all.
- Even with class components, React's team has never found a use case where inheritance is preferable to composition.
When should you use a render callback prop instead of a ReactNode slot?
- Use a render callback (
(data: T) => ReactNode) when the slot content needs access to internal state from the parent component. - Use a plain
ReactNodeslot when the content is independent of the component's internal state. - Render callbacks add flexibility but also complexity. Start with
ReactNodeand upgrade only when needed.
How do you type a component that accepts both children and named slots in TypeScript?
interface LayoutProps {
header: ReactNode;
sidebar: ReactNode;
children: ReactNode;
}
function Layout({ header, sidebar, children }: LayoutProps) {
return (
<div>
<header>{header}</header>
<aside>{sidebar}</aside>
<main>{children}</main>
</div>
);
}- All slot props are typed as
ReactNode. childrenis a standard React prop that receives nested JSX.
Related
- Render Props — When slots need access to internal component state
- Compound Components — Multi-part components that share implicit state
- Context Patterns — Avoiding prop drilling in deeply composed trees