If building a React app is like assembling LEGO blocks, then Component Composition is the art that lets you create masterpieces from the smallest bricks. Anyone can pass props
, but to build flexible, reusable, and highly extensible components, you need to master advanced composition patterns.
This article will help you go from a "builder" to a true "architect" in React. Let's explore how to turn complex, messy components into elegant and powerful structures.
Why "Composition" Over "Inheritance"?
Before diving deeper, remember React's golden philosophy: Composition over Inheritance.
Instead of creating complex inheritance chains (Component B extends A, C extends B), React encourages us to build specialized, small components and "plug" them together like puzzle pieces. This approach helps avoid issues like:
- Prop Drilling: Passing props through many layers of components that don't need them.
- Poor reusability: Logic and UI are tightly coupled.
- Rigid structure: Hard to change or extend later.
Now, buckle up and let's explore composition patterns that will change the way you write React forever!
1. Containment & Specialization
This is the most basic but extremely powerful composition pattern, often overlooked. It uses props.children
to create flexible "containers".
🤔 The Problem:
You have a Card
component, but its content could be anything: an article, a product, a user profile... How can Card
avoid caring about the details inside?
✨ Solution: Use props.children
The Card
component just defines the frame (border, shadow, etc.) and leaves a "hole" for content to be passed in via props.children
.
// Card.js
function Card(props) {
return <div className="card">{props.children}</div>
}
// App.js
function App() {
return (
<Card>
<h1>Welcome to the Blog!</h1>
<p>This is an example of the Containment Pattern.</p>
</Card>
)
}
🧠 Going Further with "Slots" (Specialization)
What if you need more than one "hole"? For example, a Dialog
needs a title
, message
, and actions
(buttons).
// Dialog.js
function Dialog(props) {
return (
<div className="dialog-container">
<header className="dialog-header">{props.title}</header>
<main className="dialog-body">{props.message}</main>
<footer className="dialog-footer">{props.actions}</footer>
</div>
)
}
// App.js
function App() {
return (
<Dialog
title={<h1>Warning!</h1>}
message={<p>Are you sure you want to delete?</p>}
actions={
<>
<button>Cancel</button>
<button>Delete</button>
</>
}
/>
)
}
When to use: When you want to create generic layout components (Modal, Card, Sidebar, PageLayout) without caring about their specific content.
2. Higher-Order Components (HOCs)
HOC is a classic React concept. It's a function that takes a component and returns a new component with added logic or props.
🤔 The Problem:
Many components (e.g., UserProfile
, CommentList
, Article
) need to fetch data from an API. Should we repeat fetch logic everywhere?
✨ Solution: Create a withData
HOC
This HOC wraps the original component, handles data fetching, and passes the result as props.
// withData.js
import React, { useState, useEffect } from 'react'
function withData(WrappedComponent, dataSource) {
return function (props) {
const [data, setData] = useState(null)
useEffect(() => {
fetch(dataSource)
.then((res) => res.json())
.then((data) => setData(data))
}, [])
if (!data) {
return <div>Loading...</div>
}
return <WrappedComponent data={data} {...props} />
}
}
// CommentList.js
function CommentList({ data, ...props }) {
return (
<ul>
{data.map((comment) => (
<li key={comment.id}>{comment.body}</li>
))}
</ul>
)
}
export const CommentListWithData = withData(
CommentList,
'https://api.example.com/comments',
)
When to use:
- Sharing logic across multiple components (cross-cutting concerns) like authentication, logging, data fetching.
- Manipulating props before passing them to the wrapped component.
✅ Pros: Logic is highly reusable.
❌ Cons: Can cause "wrapper hell" (too many nested components in React DevTools), making it hard to track props.
3. Render Props
Render Props is a powerful technique for sharing logic by passing a function as a prop. The component calls this function and provides it with the data needed to render the UI.
🤔 The Problem:
You want a MouseTracker
component to track mouse position, but different components may want to display that position differently (show coordinates, move an image, etc.).
✨ Solution: Use a render
prop
The MouseTracker
manages the mouse logic and calls props.render
with the (x, y)
coordinates. The parent decides what to render.
// MouseTracker.js
import React, { useState } from 'react'
function MouseTracker(props) {
const [position, setPosition] = useState({ x: 0, y: 0 })
const handleMouseMove = (event) => {
setPosition({
x: event.clientX,
y: event.clientY,
})
}
return (
<div style={{ height: '100vh' }} onMouseMove={handleMouseMove}>
{props.render(position)}
</div>
)
}
// App.js
function App() {
return (
<div>
<h1>Move your mouse around!</h1>
<MouseTracker
render={({ x, y }) => (
<p>
Mouse position: ({x}, {y})
</p>
)}
/>
</div>
)
}
Note: The prop doesn't have to be called render
. Often, people use children
as a function (<MouseTracker>{mouse => ...}</MouseTracker>
), known as the "Function as Children Pattern".
When to use:
- When you want to share state or dynamic logic with other components.
- As a flexible alternative to HOCs, avoiding "wrapper hell".
✅ Pros: Very clear prop origins, highly flexible.
❌ Cons: Can create nested JSX structures.
4. Custom Hooks (the modern king)
With the advent of React Hooks, Custom Hooks have become the most modern and preferred way to share stateful logic between components. They elegantly solve the problems of HOCs and Render Props.
🤔 The Problem:
You want to share logic (fetching data, tracking mouse, accessing local storage...), but want a simple way—no wrappers, no render props.
✨ Solution: Create a Custom Hook
A Custom Hook is just a JavaScript function starting with use
that can call other Hooks inside (useState
, useEffect
, etc.).
// useMousePosition.js
import { useState, useEffect } from 'react'
export function useMousePosition() {
const [position, setPosition] = useState({ x: 0, y: 0 })
useEffect(() => {
const handleMouseMove = (e) => {
setPosition({ x: e.clientX, y: e.clientY })
}
window.addEventListener('mousemove', handleMouseMove)
return () => {
window.removeEventListener('mousemove', handleMouseMove)
}
}, [])
return position
}
// App.js
import { useMousePosition } from './useMousePosition'
function App() {
const { x, y } = useMousePosition()
return (
<div>
<h1>
Current mouse position: ({x}, {y})
</h1>
</div>
)
}
When to use: Almost always when you want to share stateful logic. This is usually the top choice in modern React.
✅ Pros: Super simple, no extra wrappers, easy to read and test.
❌ Cons: Only works in Function Components.
5. Compound Components
This pattern lets you create components that are implicitly related and work as a unified whole, providing a very expressive and flexible API for users. Think of the <select>
and <option>
pair in HTML.
🤔 The Problem:
You want to build a complex Tabs
component. Users need full control over the structure and style of each Tab
and TabPanel
, but want the tab switching logic managed automatically.
✨ Solution: Use React Context
Create a parent component (Tabs
) that manages all state (which tab is active) via Context. Child components (Tab
, TabPanel
) read from that Context to know how to render themselves.
// Tabs.js
import React, { useState, useContext, createContext } from 'react'
const TabContext = createContext()
export function Tabs({ children }) {
const [activeTab, setActiveTab] = useState('tab1')
const value = { activeTab, setActiveTab }
return <TabContext.Provider value={value}>{children}</TabContext.Provider>
}
export function TabList({ children }) {
return <div className="tab-list">{children}</div>
}
export function Tab({ id, children }) {
const { activeTab, setActiveTab } = useContext(TabContext)
const isActive = activeTab === id
return (
<button
onClick={() => setActiveTab(id)}
className={isActive ? 'active' : ''}
>
{children}
</button>
)
}
export function TabPanel({ id, children }) {
const { activeTab } = useContext(TabContext)
return activeTab === id ? <div>{children}</div> : null
}
// App.js
import { Tabs, TabList, Tab, TabPanel } from './Tabs'
function App() {
return (
<Tabs>
<TabList>
<Tab id="tab1">Tab 1</Tab>
<Tab id="tab2">Tab 2</Tab>
</TabList>
<TabPanel id="tab1">
<p>This is the content of Tab 1.</p>
</TabPanel>
<TabPanel id="tab2">
<p>And this is the content of Tab 2.</p>
</TabPanel>
</Tabs>
)
}
When to use: When building complex UI component libraries that need a declarative API, like Tabs, Accordion, Dropdown Menu, etc.
Summary: When to Use What?
Pattern | Best Use Case | Pros |
---|---|---|
Containment/Slots | Building layout components (Card, Modal, Dialog). | Simple, intuitive. |
Higher-Order Comp. | Add shared logic to Class Components or legacy codebases. | Great for logic reuse. |
Render Props | Share dynamic state when you need maximum render control. | Clear, powerful. |
Custom Hooks | Default choice for sharing stateful logic in modern React. | Clean, simple, no wrappers. |
Compound Comp. | Building complex UI systems with expressive APIs. | Declarative, flexible for users. |
Mastering these component composition patterns not only makes your code cleaner and more maintainable, but also opens up a new way of thinking about app structure. Start applying them in your next project and you'll see the magic.
Happy coding with React!