React Component Patterns: Composition vs Inheritance
React’s component model encourages composition over inheritance. After building complex React applications, I’ve learned that choosing the right pattern makes the difference between maintainable code and a tangled mess. Here are the patterns that work in production.
Composition Over Inheritance
React’s philosophy: “Composition is more powerful than inheritance.” This means building complex UIs by combining simple components rather than extending base classes.
Basic Composition
// Instead of inheritance
class Button extends BaseComponent { }
// Use composition
function Button({ children, onClick, variant }) {
return (
<button
className={`btn btn-${variant}`}
onClick={onClick}
>
{children}
</button>
);
}
// Compose components
function App() {
return (
<div>
<Button variant="primary" onClick={handleClick}>
Click Me
</Button>
<Button variant="secondary">
Cancel
</Button>
</div>
);
}
Container vs Presentational Components
Separate data logic from presentation:
// Container Component (Smart)
class UserListContainer extends React.Component {
state = { users: [], loading: true };
componentDidMount() {
fetch('/api/users')
.then(res => res.json())
.then(users => this.setState({ users, loading: false }));
}
render() {
return (
<UserList
users={this.state.users}
loading={this.state.loading}
/>
);
}
}
// Presentational Component (Dumb)
function UserList({ users, loading }) {
if (loading) return <div>Loading...</div>;
return (
<ul>
{users.map(user => (
<li key={user.id}>{user.name}</li>
))}
</ul>
);
}
Higher-Order Components (HOCs)
HOCs wrap components to add functionality:
// HOC for data fetching
function withData(WrappedComponent, dataSource) {
return class extends React.Component {
state = { data: null, loading: true, error: null };
componentDidMount() {
dataSource()
.then(data => this.setState({ data, loading: false }))
.catch(error => this.setState({ error, loading: false }));
}
render() {
const { data, loading, error } = this.state;
if (loading) return <div>Loading...</div>;
if (error) return <div>Error: {error.message}</div>;
return <WrappedComponent data={data} {...this.props} />;
}
};
}
// Usage
const UserListWithData = withData(
UserList,
() => fetch('/api/users').then(res => res.json())
);
Multiple HOCs
// HOC for authentication
function withAuth(WrappedComponent) {
return class extends React.Component {
componentDidMount() {
if (!this.props.isAuthenticated) {
this.props.history.push('/login');
}
}
render() {
if (!this.props.isAuthenticated) {
return null;
}
return <WrappedComponent {...this.props} />;
}
};
}
// HOC for logging
function withLogging(WrappedComponent) {
return class extends React.Component {
componentDidMount() {
console.log(`Component ${WrappedComponent.name} mounted`);
}
render() {
return <WrappedComponent {...this.props} />;
}
};
}
// Compose multiple HOCs
const EnhancedComponent = withLogging(withAuth(withData(UserList)));
Render Props Pattern
Render props pass a function as a prop that returns JSX:
// Data fetching component with render prop
class DataFetcher extends React.Component {
state = { data: null, loading: true, error: null };
componentDidMount() {
this.props.fetch()
.then(data => this.setState({ data, loading: false }))
.catch(error => this.setState({ error, loading: false }));
}
render() {
return this.props.render(this.state);
}
}
// Usage
function App() {
return (
<DataFetcher
fetch={() => fetch('/api/users').then(res => res.json())}
render={({ data, loading, error }) => {
if (loading) return <div>Loading...</div>;
if (error) return <div>Error: {error.message}</div>;
return <UserList users={data} />;
}}
/>
);
}
Children as Function
// Mouse position tracker
class MouseTracker extends React.Component {
state = { x: 0, y: 0 };
handleMouseMove = (event) => {
this.setState({
x: event.clientX,
y: event.clientY
});
};
render() {
return (
<div onMouseMove={this.handleMouseMove}>
{this.props.children(this.state)}
</div>
);
}
}
// Usage
function App() {
return (
<MouseTracker>
{({ x, y }) => (
<div>
Mouse position: {x}, {y}
</div>
)}
</MouseTracker>
);
}
Compound Components
Components that work together:
// Tabs component
class Tabs extends React.Component {
state = { activeIndex: 0 };
render() {
const children = React.Children.map(
this.props.children,
(child, index) => {
return React.cloneElement(child, {
isActive: index === this.state.activeIndex,
onClick: () => this.setState({ activeIndex: index })
});
}
);
return <div className="tabs">{children}</div>;
}
}
function Tab({ isActive, onClick, children }) {
return (
<div
className={`tab ${isActive ? 'active' : ''}`}
onClick={onClick}
>
{children}
</div>
);
}
// Usage
function App() {
return (
<Tabs>
<Tab>Tab 1</Tab>
<Tab>Tab 2</Tab>
<Tab>Tab 3</Tab>
</Tabs>
);
}
Controlled vs Uncontrolled Components
Controlled Components
class ControlledInput extends React.Component {
state = { value: '' };
handleChange = (event) => {
this.setState({ value: event.target.value });
};
render() {
return (
<input
value={this.state.value}
onChange={this.handleChange}
/>
);
}
}
Uncontrolled Components
class UncontrolledInput extends React.Component {
inputRef = React.createRef();
handleSubmit = () => {
console.log('Value:', this.inputRef.current.value);
};
render() {
return (
<div>
<input ref={this.inputRef} />
<button onClick={this.handleSubmit}>Submit</button>
</div>
);
}
}
Custom Hooks (React 16.8+)
Hooks provide a cleaner alternative to HOCs and render props:
// Custom hook for data fetching
function useData(fetchFn) {
const [data, setData] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
useEffect(() => {
fetchFn()
.then(data => {
setData(data);
setLoading(false);
})
.catch(err => {
setError(err);
setLoading(false);
});
}, []);
return { data, loading, error };
}
// Usage
function UserList() {
const { data, loading, error } = useData(
() => fetch('/api/users').then(res => res.json())
);
if (loading) return <div>Loading...</div>;
if (error) return <div>Error: {error.message}</div>;
return (
<ul>
{data.map(user => (
<li key={user.id}>{user.name}</li>
))}
</ul>
);
}
When to Use Each Pattern
Use HOCs when:
- Adding cross-cutting concerns (auth, logging, analytics)
- Need to wrap multiple components with same logic
- Working with class components
Use Render Props when:
- Need flexibility in rendering
- Sharing stateful logic
- Want explicit control over rendering
Use Hooks when:
- Using functional components
- Need to share stateful logic
- Want cleaner, more readable code
Use Composition when:
- Building UI from smaller pieces
- Need flexibility in component structure
- Creating reusable components
Best Practices
- Prefer composition - Build complex components from simple ones
- Keep components focused - Single responsibility principle
- Use HOCs sparingly - Can create prop drilling issues
- Document component APIs - Make props and usage clear
- Test components in isolation - Easier to test simple components
Conclusion
React’s composition model is powerful:
- Build complex UIs from simple components
- Reuse logic with HOCs, render props, or hooks
- Keep components focused and testable
- Choose the right pattern for your use case
Start with simple composition, then add HOCs or render props when you need to share logic. With React 16.8+, hooks often provide the cleanest solution.
React component patterns from February 2017, covering React 15.x patterns before hooks were introduced.