Container-Presenter: Separate Data Logic from Rendering
How to separate data-fetching logic from rendering in React using the container-presenter pattern. Covers hooks migration, testing benefits, and trade-offs.
Note: This guide follows English-language naming conventions and terminology standards common in international development teams. Examples use English identifiers and comments to maximize compatibility across codebases and tooling.
Overview
The container-presenter pattern splits a React component into two parts: a container that manages data (fetching, state, business logic) and a presenter that renders UI based on props. The container knows how to get data and what to do with it. The presenter knows how to display it. This separation makes presenters reusable across different data sources, simplifies unit testing, and keeps rendering logic free of side effects.
When to Use
- Components that mix data-fetching with complex rendering logic
- UI components that should be reusable with different data sources
- Teams where data logic and UI design are handled by different people
- Testing strategies that require isolating rendering from data dependencies
- Shared component libraries where consumers provide their own data
When NOT to Use
- Simple components with no data-fetching — a single component is fine
- Small applications where the overhead of two files per component isn’t justified
- React hooks have largely replaced this pattern for data logic — prefer custom hooks for new code
- Components where data and rendering are tightly coupled and never reused
Solution
Classic container-presenter (class components)
// UserListContainer.jsx — manages data
import React, { Component } from 'react';
import UserListPresenter from './UserListPresenter';
class UserListContainer extends Component {
state = {
users: [],
loading: true,
error: null,
};
componentDidMount() {
this.fetchUsers();
}
async fetchUsers() {
this.setState({ loading: true, error: null });
try {
const response = await fetch('/api/users');
const users = await response.json();
this.setState({ users, loading: false });
} catch (error) {
this.setState({ error: error.message, loading: false });
}
}
handleDelete = async (userId) => {
await fetch(`/api/users/${userId}`, { method: 'DELETE' });
this.fetchUsers();
};
handleSearch = (query) => {
const filtered = this.state.users.filter(u =>
u.name.toLowerCase().includes(query.toLowerCase())
);
this.setState({ users: filtered });
};
render() {
return (
<UserListPresenter
users={this.state.users}
loading={this.state.loading}
error={this.state.error}
onDelete={this.handleDelete}
onSearch={this.handleSearch}
/>
);
}
}
export default UserListContainer;
// UserListPresenter.jsx — pure rendering
import React from 'react';
import PropTypes from 'prop-types';
function UserListPresenter({ users, loading, error, onDelete, onSearch }) {
if (loading) return <div className="spinner">Loading users...</div>;
if (error) return <div className="error">Failed to load: {error}</div>;
return (
<div className="user-list">
<input
type="search"
placeholder="Search users..."
onChange={(e) => onSearch(e.target.value)}
className="search-input"
/>
<ul>
{users.map(user => (
<li key={user.id} className="user-item">
<span>{user.name} — {user.email}</span>
<button onClick={() => onDelete(user.id)} className="btn-delete">
Delete
</button>
</li>
))}
</ul>
{users.length === 0 && <p>No users found.</p>}
</div>
);
}
UserListPresenter.propTypes = {
users: PropTypes.arrayOf(PropTypes.shape({
id: PropTypes.number.isRequired,
name: PropTypes.string.isRequired,
email: PropTypes.string.isRequired,
})).isRequired,
loading: PropTypes.bool.isRequired,
error: PropTypes.string,
onDelete: PropTypes.func.isRequired,
onSearch: PropTypes.func.isRequired,
};
export default UserListPresenter;
Hooks-based container (modern approach)
// useUsers.js — custom hook as container
import { useState, useEffect, useCallback } from 'react';
function useUsers() {
const [users, setUsers] = useState([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
const fetchUsers = useCallback(async () => {
setLoading(true);
setError(null);
try {
const response = await fetch('/api/users');
const data = await response.json();
setUsers(data);
} catch (err) {
setError(err.message);
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
fetchUsers();
}, [fetchUsers]);
const deleteUser = useCallback(async (userId) => {
await fetch(`/api/users/${userId}`, { method: 'DELETE' });
setUsers(prev => prev.filter(u => u.id !== userId));
}, []);
const searchUsers = useCallback((query) => {
setUsers(prev => {
const allUsers = prev;
if (!query) return allUsers;
return allUsers.filter(u =>
u.name.toLowerCase().includes(query.toLowerCase())
);
});
}, []);
return { users, loading, error, deleteUser, searchUsers, refetch: fetchUsers };
}
export default useUsers;
// UserList.jsx — presenter component using the hook
import React from 'react';
import useUsers from '../hooks/useUsers';
function UserList() {
const { users, loading, error, deleteUser, searchUsers } = useUsers();
if (loading) return <div className="spinner">Loading users...</div>;
if (error) return <div className="error">Failed to load: {error}</div>;
return (
<div className="user-list">
<input
type="search"
placeholder="Search users..."
onChange={(e) => searchUsers(e.target.value)}
/>
<ul>
{users.map(user => (
<li key={user.id}>
<span>{user.name} — {user.email}</span>
<button onClick={() => deleteUser(user.id)}>Delete</button>
</li>
))}
</ul>
</div>
);
}
export default UserList;
Reusable presenter with different data sources
// ProductGrid.jsx — reusable presenter
import React from 'react';
function ProductGrid({ products, loading, error, onAddToCart, layout = 'grid' }) {
if (loading) return <div className="skeleton-grid">Loading products...</div>;
if (error) return <div className="error-banner">{error}</div>;
return (
<div className={`product-${layout}`}>
{products.map(product => (
<div key={product.id} className="product-card">
<img src={product.image} alt={product.name} />
<h3>{product.name}</h3>
<p className="price">${product.price}</p>
<button onClick={() => onAddToCart(product.id)}>
Add to Cart
</button>
</div>
))}
</div>
);
}
export default ProductGrid;
// FeaturedProducts.jsx — container using the same presenter
import React, { useState, useEffect } from 'react';
import ProductGrid from './ProductGrid';
function FeaturedProducts() {
const [products, setProducts] = useState([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
useEffect(() => {
fetch('/api/products?featured=true')
.then(res => res.json())
.then(data => { setProducts(data); setLoading(false); })
.catch(err => { setError(err.message); setLoading(false); });
}, []);
const handleAddToCart = (productId) => {
fetch('/api/cart', {
method: 'POST',
body: JSON.stringify({ productId, quantity: 1 }),
});
};
return (
<section className="featured">
<h2>Featured Products</h2>
<ProductGrid
products={products}
loading={loading}
error={error}
onAddToCart={handleAddToCart}
layout="carousel"
/>
</section>
);
}
// SearchResults.jsx — different container, same presenter
import React, { useState, useEffect } from 'react';
import ProductGrid from './ProductGrid';
function SearchResults({ query }) {
const [products, setProducts] = useState([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
useEffect(() => {
setLoading(true);
fetch(`/api/products?q=${encodeURIComponent(query)}`)
.then(res => res.json())
.then(data => { setProducts(data); setLoading(false); })
.catch(err => { setError(err.message); setLoading(false); });
}, [query]);
const handleAddToCart = (productId) => {
fetch('/api/cart', {
method: 'POST',
body: JSON.stringify({ productId, quantity: 1 }),
});
};
return (
<section className="search-results">
<h2>Results for "{query}"</h2>
<ProductGrid
products={products}
loading={loading}
error={error}
onAddToCart={handleAddToCart}
layout="list"
/>
</section>
);
}
Testing the presenter in isolation
// UserListPresenter.test.jsx
import { render, screen, fireEvent } from '@testing-library/react';
import UserListPresenter from './UserListPresenter';
describe('UserListPresenter', () => {
const mockUsers = [
{ id: 1, name: 'Alice', email: 'alice@example.com' },
{ id: 2, name: 'Bob', email: 'bob@example.com' },
];
it('renders users', () => {
render(
<UserListPresenter
users={mockUsers}
loading={false}
error={null}
onDelete={() => {}}
onSearch={() => {}}
/>
);
expect(screen.getByText('Alice — alice@example.com')).toBeInTheDocument();
expect(screen.getByText('Bob — bob@example.com')).toBeInTheDocument();
});
it('shows loading state', () => {
render(
<UserListPresenter
users={[]}
loading={true}
error={null}
onDelete={() => {}}
onSearch={() => {}}
/>
);
expect(screen.getByText('Loading users...')).toBeInTheDocument();
});
it('shows error message', () => {
render(
<UserListPresenter
users={[]}
loading={false}
error="Network error"
onDelete={() => {}}
onSearch={() => {}}
/>
);
expect(screen.getByText('Failed to load: Network error')).toBeInTheDocument();
});
it('calls onDelete when delete button clicked', () => {
const onDelete = jest.fn();
render(
<UserListPresenter
users={mockUsers}
loading={false}
error={null}
onDelete={onDelete}
onSearch={() => {}}
/>
);
fireEvent.click(screen.getAllByText('Delete')[0]);
expect(onDelete).toHaveBeenCalledWith(1);
});
it('calls onSearch when typing in search box', () => {
const onSearch = jest.fn();
render(
<UserListPresenter
users={mockUsers}
loading={false}
error={null}
onDelete={() => {}}
onSearch={onSearch}
/>
);
fireEvent.change(screen.getByPlaceholderText('Search users...'), {
target: { value: 'Alice' },
});
expect(onSearch).toHaveBeenCalledWith('Alice');
});
});
TypeScript with typed props
// UserListPresenter.tsx — typed presenter
import React from 'react';
interface User {
id: number;
name: string;
email: string;
}
interface UserListPresenterProps {
users: User[];
loading: boolean;
error: string | null;
onDelete: (userId: number) => void;
onSearch: (query: string) => void;
}
function UserListPresenter({
users,
loading,
error,
onDelete,
onSearch,
}: UserListPresenterProps): JSX.Element {
if (loading) return <div className="spinner">Loading users...</div>;
if (error) return <div className="error">{error}</div>;
return (
<div className="user-list">
<input
type="search"
placeholder="Search users..."
onChange={(e) => onSearch(e.target.value)}
/>
<ul>
{users.map(user => (
<li key={user.id}>
<span>{user.name} — {user.email}</span>
<button onClick={() => onDelete(user.id)}>Delete</button>
</li>
))}
</ul>
</div>
);
}
export default UserListPresenter;
Variants
Render props as container
// DataContainer.jsx — container as render prop
import React, { useState, useEffect } from 'react';
function DataContainer({ url, render }) {
const [data, setData] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
useEffect(() => {
setLoading(true);
fetch(url)
.then(res => res.json())
.then(d => { setData(d); setLoading(false); })
.catch(e => { setError(e.message); setLoading(false); });
}, [url]);
return render({ data, loading, error });
}
// Usage — presenter is inline
<DataContainer url="/api/users" render={({ data, loading, error }) => (
loading ? <Spinner /> : <UserList users={data} />
)} />
Higher-order component as container
// withData.jsx — HOC container
import React, { useState, useEffect } from 'react';
function withData(url, WrappedComponent) {
return function DataContainer(props) {
const [data, setData] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
useEffect(() => {
fetch(url)
.then(res => res.json())
.then(d => { setData(d); setLoading(false); })
.catch(e => { setError(e.message); setLoading(false); });
}, [url]);
return <WrappedComponent data={data} loading={loading} error={error} {...props} />;
};
}
// Usage
const UserListContainer = withData('/api/users', UserListPresenter);
Best Practices
-
For a deeper guide, see CSS Architecture: Utility-First with Component-Scoped Layers.
-
Keep presenters pure — no side effects, no API calls, no state management beyond UI state
-
Pass callbacks, not data sources — presenters receive
onDelete(id), not a fetch function -
Use TypeScript for props — typed props catch mismatches between container and presenter
-
Prefer custom hooks over HOCs or render props — hooks are the modern way to extract container logic
-
Test presenters in isolation — render with mock props, assert on output, verify callbacks
-
Keep presenters reusable — avoid domain-specific props; use generic names like
itemsnotusers
Common Mistakes
- Putting business logic in presenters: the presenter should only render. Validation, transformation, and API calls belong in the container or hook.
- Making presenters too specific: a
UserListthat only works with/api/usersisn’t reusable. AcceptitemsandonItemDeleteinstead. - Not testing in isolation: if you can’t render the presenter with mock props without mocking fetch, the separation is incomplete.
- Over-splitting simple components: a button that opens a dropdown doesn’t need a container-presenter split. Reserve this for components with real data logic.
FAQ
Is container-presenter still relevant with hooks?
Yes, but the implementation changed. Custom hooks replace container components — the hook manages data, the component renders. The principle of separating data from rendering remains the same.
When should I use container-presenter vs. a single component?
When the component has both data-fetching and non-trivial rendering logic, or when the same UI could be used with different data sources. If the component is simple, keep it as one file.
What’s the difference between container-presenter and smart-dumb components?
They’re the same pattern with different names. Container = smart (manages data), presenter = dumb (renders props). The terms are interchangeable.
Should presenters have any state?
Only UI state — like expanded/collapsed, active tab, hover. Business state (selected user, filtered list) belongs in the container or hook.
Can I use this pattern with Vue or Angular?
The principle applies to any framework. In Vue, use a container component with a presentational child. In Angular, use a smart component wrapping a dumb component.
Related Resources
Custom Hook Composition
How to compose reusable logic with custom React hooks. Covers hook composition patterns, dependency arrays, context integration, and testing strategies.
PatternOptimistic Update: Update UI Immediately, Reconcile on
How to update UI immediately and reconcile on server response in React. Covers rollback on error, conflict resolution, and React Query integration.
PatternSuspense Boundary: Declarative Loading States with React
How to use React Suspense boundaries for declarative loading states. Covers data fetching, streaming SSR, nested boundaries, and error boundaries.
PatternCSS Architecture: Utility-First with Component-Scoped Layers
How to organize CSS with utility-first classes and component-scoped layers. Covers Tailwind CSS, CSS layers, BEM, CSS modules, and design tokens.
PatternProgressive Enhancement
How to build a functional HTML baseline and progressively enhance with JavaScript. Covers core functionality, feature detection, graceful degradation, and accessibility.
PatternState Machine UI: Finite State Machines for UI
How to model UI state transitions with finite state machines in React. Covers XState, statecharts, guarded transitions, and preventing impossible states.