Mastering React with the Container-Presenter Pattern: A Deep Dive
In the evolving landscape of React development, writing maintainable, scalable, and testable code isn't optional—it's essential. The Container-Presenter Pattern (also called Smart-Dumb Components, though the former terminology is preferred for its neutral connotation) stands as one of the most fundamental architectural patterns for achieving clean component design.
This comprehensive guide will walk you through everything you need to know: understanding the pattern's philosophy, recognizing when your code needs refactoring, implementing it step-by-step with detailed examples, and making informed decisions about when to use (and not use) this pattern.
🔍 Understanding the Fundamental Problem
The Monolithic Component Anti-Pattern
Let's start with a real-world scenario. You're tasked with building a user profile page for a social media application. The requirements seem straightforward:
- Fetch and display user information (name, email, bio, avatar)
- Show a list of the user's posts
- Allow inline editing of profile details
- Handle loading states during data fetches
- Display error messages and provide retry functionality
- Track whether the user is in "edit mode"
At first, you might be tempted to build this all in one component. Here's what that typically looks like:
// UserProfile.jsx (THE PROBLEMATIC MONOLITH)
import { useState, useEffect } from 'react';
import axios from 'axios';
import './UserProfile.css';
const UserProfile = ({ userId }) => {
// State management (6 different pieces of state!)
const [user, setUser] = useState(null);
const [posts, setPosts] = useState([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
const [isEditing, setIsEditing] = useState(false);
const [formData, setFormData] = useState({ name: '', email: '', bio: '' });
// Data fetching logic
const fetchUserData = async () => {
try {
setLoading(true);
setError(null);
const response = await axios.get(`/api/users/${userId}`);
setUser(response.data);
setFormData({
name: response.data.name,
email: response.data.email,
bio: response.data.bio
});
} catch (err) {
setError(err.message || 'Failed to fetch user data');
} finally {
setLoading(false);
}
};
const fetchUserPosts = async () => {
try {
const response = await axios.get(`/api/posts?userId=${userId}`);
setPosts(response.data);
} catch (err) {
console.error('Failed to fetch posts:', err);
}
};
// Business logic
const handleUpdateUser = async () => {
try {
setLoading(true);
const response = await axios.put(`/api/users/${userId}`, formData);
setUser(response.data);
setIsEditing(false);
} catch (err) {
setError('Failed to update profile');
} finally {
setLoading(false);
}
};
const handleInputChange = (e) => {
const { name, value } = e.target;
setFormData(prev => ({ ...prev, [name]: value }));
};
const handleRetry = () => {
setError(null);
fetchUserData();
fetchUserPosts();
};
const handleStartEdit = () => {
setIsEditing(true);
setFormData({
name: user.name,
email: user.email,
bio: user.bio
});
};
const handleCancelEdit = () => {
setIsEditing(false);
setFormData({
name: user.name,
email: user.email,
bio: user.bio
});
};
// Effects
useEffect(() => {
fetchUserData();
fetchUserPosts();
}, [userId]);
// Rendering logic (80+ lines of JSX!)
if (loading) {
return (
<div className="loading-container">
<div className="spinner"></div>
<p>Loading user profile...</p>
</div>
);
}
if (error) {
return (
<div className="error-container">
<h2>Oops! Something went wrong</h2>
<p>{error}</p>
<button onClick={handleRetry}>Try Again</button>
</div>
);
}
return (
<div className="user-profile">
<div className="profile-header">
<img src={user.avatar} alt={user.name} className="avatar" />
{isEditing ? (
<div className="edit-form">
<input
type="text"
name="name"
value={formData.name}
onChange={handleInputChange}
placeholder="Name"
/>
<input
type="email"
name="email"
value={formData.email}
onChange={handleInputChange}
placeholder="Email"
/>
<textarea
name="bio"
value={formData.bio}
onChange={handleInputChange}
placeholder="Bio"
rows="4"
/>
<div className="button-group">
<button onClick={handleUpdateUser} className="save-btn">
Save Changes
</button>
<button onClick={handleCancelEdit} className="cancel-btn">
Cancel
</button>
</div>
</div>
) : (
<div className="profile-info">
<h1>{user.name}</h1>
<p className="email">{user.email}</p>
<p className="bio">{user.bio}</p>
<button onClick={handleStartEdit} className="edit-btn">
Edit Profile
</button>
</div>
)}
</div>
<div className="posts-section">
<h2>Recent Posts</h2>
{posts.length === 0 ? (
<p className="no-posts">No posts yet</p>
) : (
<ul className="posts-list">
{posts.map(post => (
<li key={post.id} className="post-item">
<h3>{post.title}</h3>
<p>{post.content}</p>
<span className="post-date">
{new Date(post.createdAt).toLocaleDateString()}
</span>
</li>
))}
</ul>
)}
</div>
</div>
);
};
export default UserProfile;
Why This Approach Fails at Scale
This 150+ line monolithic component demonstrates several critical code smells:
1. Violation of Single Responsibility Principle
The component is responsible for:
- API communication (data fetching and mutations)
- State management (6 different state variables)
- Business logic (form validation, edit mode toggling)
- UI rendering (loading states, error states, profile display, posts list)
- Event handling (user interactions)
A single change request (like adding a profile picture upload) requires touching a file that already manages too many concerns—increasing bug risk exponentially.
2. Testing Nightmare
To test this component, you need to:
- Mock the entire axios library
- Simulate complex user interactions (clicking edit, typing, saving)
- Manage asynchronous state updates
- Test loading and error states
- Verify UI rendering for multiple conditional branches
A single test file ends up being 200+ lines with intricate setup logic.
3. Zero Reusability
Want to use that loading spinner on another page? You'll copy-paste the JSX. Need the error message component elsewhere? Copy-paste again. This leads to code duplication and inconsistent UIs.
4. Poor Readability and Maintainability
New developers joining the project need to understand the entire component to make even small changes. The mental model required is too complex—mixing data flow with presentation logic.
5. Difficult to Optimize
React's rendering optimization tools (like React.memo
, useMemo
, useCallback
) become harder to apply effectively when logic and UI are entangled.
🎯 The Container-Presenter Solution
The Container-Presenter pattern addresses these issues through separation of concerns—splitting components into two distinct categories based on their responsibilities.
📦 Container Components (The "Brain")
Primary Responsibilities:
- Manage application state (
useState
,useReducer
) - Handle side effects (
useEffect
, data fetching) - Implement business logic (validation, calculations, transformations)
- Connect to external data sources (APIs, Context, Redux)
- Process user actions and update state accordingly
Key Characteristics:
- Contains minimal to no JSX—typically just renders a single Presenter component
- Passes data and callback functions as props
- Often named with
Container
suffix or placed in acontainers/
directory - Can use hooks freely for state management and side effects
- May connect to global state management systems
What Containers DON'T Do:
- Define styling or CSS classes
- Contain complex JSX structures
- Handle layout or visual presentation
- Worry about accessibility attributes (like ARIA labels)
🖼️ Presenter Components (The "Face")
Primary Responsibilities:
- Receive data via props (never fetch it themselves)
- Render UI elements based on received data
- Handle UI-only state (dropdown open/closed, tabs selection, accordion expansion)
- Define component styling and layout
- Implement accessibility features
Key Characteristics:
- Pure functional components (can be memoized easily)
- No knowledge of data source (API, Context, Redux, etc.)
- Highly reusable across different contexts
- Easy to test with simple prop passing
- Can be showcased in Storybook or similar tools
What Presenters DON'T Do:
- Make API calls or network requests
- Manage application-level state
- Implement complex business logic
- Directly interact with browser APIs (localStorage, etc.)
🧩 The Mental Model
Think of it like a restaurant:
- Container = Kitchen: Where the food is prepared, ingredients are managed, orders are processed. You don't see it, but it's doing all the complex work.
- Presenter = Dining Room: Where the food is beautifully presented, the ambiance is set, and customers interact. It looks great but doesn't cook anything.
🔨 Step-by-Step Refactoring: From Monolith to Clean Architecture
Let's transform our monolithic UserProfile
component into a clean, maintainable architecture using the Container-Presenter pattern.
Phase 1: Create the Container Component
The container will handle all data management and business logic.
// containers/UserProfileContainer.jsx
import { useState, useEffect } from 'react';
import axios from 'axios';
import UserProfilePresenter from '../components/UserProfilePresenter';
const UserProfileContainer = ({ userId }) => {
// ========================================
// STATE MANAGEMENT
// ========================================
const [user, setUser] = useState(null);
const [posts, setPosts] = useState([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
// ========================================
// DATA FETCHING LOGIC
// ========================================
const fetchUserData = async () => {
try {
setLoading(true);
setError(null);
const response = await axios.get(`/api/users/${userId}`);
setUser(response.data);
} catch (err) {
setError(err.response?.data?.message || 'Failed to fetch user data');
console.error('Error fetching user:', err);
} finally {
setLoading(false);
}
};
const fetchUserPosts = async () => {
try {
const response = await axios.get(`/api/posts?userId=${userId}`);
setPosts(response.data);
} catch (err) {
console.error('Error fetching posts:', err);
// We don't block the UI if posts fail - user profile still shows
}
};
// ========================================
// BUSINESS LOGIC & EVENT HANDLERS
// ========================================
const handleUpdateUser = async (formData) => {
try {
setLoading(true);
const response = await axios.put(`/api/users/${userId}`, formData);
setUser(response.data);
return { success: true };
} catch (err) {
console.error('Error updating user:', err);
return {
success: false,
error: err.response?.data?.message || 'Failed to update profile'
};
} finally {
setLoading(false);
}
};
const handleRetry = () => {
setError(null);
fetchUserData();
fetchUserPosts();
};
const handleDeletePost = async (postId) => {
try {
await axios.delete(`/api/posts/${postId}`);
setPosts(prevPosts => prevPosts.filter(post => post.id !== postId));
return { success: true };
} catch (err) {
console.error('Error deleting post:', err);
return {
success: false,
error: 'Failed to delete post'
};
}
};
// ========================================
// SIDE EFFECTS
// ========================================
useEffect(() => {
if (userId) {
fetchUserData();
fetchUserPosts();
}
}, [userId]);
// ========================================
// RENDER (Minimal JSX - Just passes data down)
// ========================================
return (
<UserProfilePresenter
user={user}
posts={posts}
loading={loading}
error={error}
onRetry={handleRetry}
onUpdateUser={handleUpdateUser}
onDeletePost={handleDeletePost}
/>
);
};
export default UserProfileContainer;
Key Observations:
- 60 lines focused purely on data and logic
- No CSS classes, no complex JSX trees
- Clear separation of concerns with comments
- Returns a single presenter component
- All callback functions are exposed as props
Phase 2: Create the Main Presenter Component
Now let's build the presenter that handles all UI rendering.
// components/UserProfilePresenter.jsx
import { useState } from 'react';
import LoadingSpinner from './common/LoadingSpinner';
import ErrorMessage from './common/ErrorMessage';
import ProfileHeader from './profile/ProfileHeader';
import PostList from './post/PostList';
import './UserProfilePresenter.css';
const UserProfilePresenter = ({
user,
posts,
loading,
error,
onRetry,
onUpdateUser,
onDeletePost
}) => {
// ========================================
// UI-ONLY STATE (Edit mode & form data)
// ========================================
const [isEditing, setIsEditing] = useState(false);
const [formData, setFormData] = useState({ name: '', email: '', bio: '' });
const [formError, setFormError] = useState('');
// ========================================
// UI EVENT HANDLERS
// ========================================
const handleStartEdit = () => {
if (user) {
setIsEditing(true);
setFormData({
name: user.name,
email: user.email,
bio: user.bio || ''
});
setFormError('');
}
};
const handleCancelEdit = () => {
setIsEditing(false);
setFormData({ name: '', email: '', bio: '' });
setFormError('');
};
const handleInputChange = (e) => {
const { name, value } = e.target;
setFormData(prev => ({ ...prev, [name]: value }));
// Clear error when user starts typing
if (formError) setFormError('');
};
const handleSaveProfile = async () => {
// Basic client-side validation
if (!formData.name.trim()) {
setFormError('Name is required');
return;
}
if (!formData.email.trim() || !formData.email.includes('@')) {
setFormError('Valid email is required');
return;
}
const result = await onUpdateUser(formData);
if (result.success) {
setIsEditing(false);
setFormError('');
} else {
setFormError(result.error);
}
};
// ========================================
// CONDITIONAL RENDERING (Loading & Error States)
// ========================================
if (loading && !user) {
return <LoadingSpinner message="Loading user profile..." />;
}
if (error) {
return (
<ErrorMessage
title="Unable to Load Profile"
message={error}
onRetry={onRetry}
/>
);
}
if (!user) {
return (
<div className="user-profile-empty">
<p>User not found</p>
</div>
);
}
// ========================================
// MAIN UI RENDER
// ========================================
return (
<div className="user-profile">
<ProfileHeader
user={user}
isEditing={isEditing}
formData={formData}
formError={formError}
loading={loading}
onStartEdit={handleStartEdit}
onCancelEdit={handleCancelEdit}
onInputChange={handleInputChange}
onSaveProfile={handleSaveProfile}
/>
<PostList
posts={posts}
onDeletePost={onDeletePost}
isLoading={loading}
/>
</div>
);
};
export default UserProfilePresenter;
Key Observations:
- Manages UI-only state (edit mode, form data)
- Coordinates between sub-components
- Handles form validation (client-side)
- No API calls—delegates to container via callbacks
- Clean conditional rendering patterns
Phase 3: Build Reusable Sub-Components
Now let's create the smaller, focused components that make up our UI.
Loading Spinner Component
// components/common/LoadingSpinner.jsx
import './LoadingSpinner.css';
const LoadingSpinner = ({ message = 'Loading...', size = 'medium' }) => {
return (
<div className="loading-container">
<div className={`spinner spinner-${size}`} role="status" aria-live="polite">
<span className="sr-only">Loading</span>
</div>
{message && <p className="loading-message">{message}</p>}
</div>
);
};
export default LoadingSpinner;
Error Message Component
// components/common/ErrorMessage.jsx
import './ErrorMessage.css';
const ErrorMessage = ({
title = 'Something went wrong',
message,
onRetry
}) => {
return (
<div className="error-container" role="alert">
<div className="error-icon">⚠️</div>
<h2 className="error-title">{title}</h2>
<p className="error-message">{message}</p>
{onRetry && (
<button
onClick={onRetry}
className="retry-button"
aria-label="Retry loading"
>
Try Again
</button>
)}
</div>
);
};
export default ErrorMessage;
Profile Header Component
// components/profile/ProfileHeader.jsx
import './ProfileHeader.css';
const ProfileHeader = ({
user,
isEditing,
formData,
formError,
loading,
onStartEdit,
onCancelEdit,
onInputChange,
onSaveProfile
}) => {
return (
<div className="profile-header">
<div className="avatar-section">
<img
src={user.avatar || '/default-avatar.png'}
alt={`${user.name}'s avatar`}
className="avatar"
/>
<span className="user-status" title={user.isOnline ? 'Online' : 'Offline'}>
{user.isOnline ? '🟢' : '⚫'}
</span>
</div>
{isEditing ? (
<div className="edit-form" role="form" aria-label="Edit profile form">
{formError && (
<div className="form-error" role="alert">
{formError}
</div>
)}
<div className="form-group">
<label htmlFor="name">Name</label>
<input
id="name"
type="text"
name="name"
value={formData.name}
onChange={onInputChange}
placeholder="Enter your name"
disabled={loading}
required
aria-required="true"
/>
</div>
<div className="form-group">
<label htmlFor="email">Email</label>
<input
id="email"
type="email"
name="email"
value={formData.email}
onChange={onInputChange}
placeholder="Enter your email"
disabled={loading}
required
aria-required="true"
/>
</div>
<div className="form-group">
<label htmlFor="bio">Bio</label>
<textarea
id="bio"
name="bio"
value={formData.bio}
onChange={onInputChange}
placeholder="Tell us about yourself"
rows="4"
disabled={loading}
maxLength="500"
/>
<span className="character-count">
{formData.bio.length}/500
</span>
</div>
<div className="button-group">
<button
onClick={onSaveProfile}
className="save-btn"
disabled={loading}
aria-label="Save profile changes"
>
{loading ? 'Saving...' : 'Save Changes'}
</button>
<button
onClick={onCancelEdit}
className="cancel-btn"
disabled={loading}
aria-label="Cancel editing"
>
Cancel
</button>
</div>
</div>
) : (
<div className="profile-info">
<h1 className="user-name">{user.name}</h1>
<p className="user-email">{user.email}</p>
{user.bio && <p className="user-bio">{user.bio}</p>}
<div className="profile-stats">
<div className="stat">
<span className="stat-value">{user.followerCount || 0}</span>
<span className="stat-label">Followers</span>
</div>
<div className="stat">
<span className="stat-value">{user.followingCount || 0}</span>
<span className="stat-label">Following</span>
</div>
<div className="stat">
<span className="stat-value">{user.postCount || 0}</span>
<span className="stat-label">Posts</span>
</div>
</div>
<button
onClick={onStartEdit}
className="edit-btn"
aria-label="Edit profile"
>
✏️ Edit Profile
</button>
</div>
)}
</div>
);
};
export default ProfileHeader;
Post List Component
// components/post/PostList.jsx
import { useState } from 'react';
import './PostList.css';
const PostList = ({ posts, onDeletePost, isLoading }) => {
const [deletingPostId, setDeletingPostId] = useState(null);
const handleDelete = async (postId) => {
if (!window.confirm('Are you sure you want to delete this post?')) {
return;
}
setDeletingPostId(postId);
const result = await onDeletePost(postId);
if (!result.success) {
alert(result.error);
}
setDeletingPostId(null);
};
const formatDate = (dateString) => {
const date = new Date(dateString);
const now = new Date();
const diffInDays = Math.floor((now - date) / (1000 * 60 * 60 * 24));
if (diffInDays === 0) return 'Today';
if (diffInDays === 1) return 'Yesterday';
if (diffInDays < 7) return `${diffInDays} days ago`;
return date.toLocaleDateString('en-US', {
month: 'short',
day: 'numeric',
year: date.getFullYear() !== now.getFullYear() ? 'numeric' : undefined
});
};
return (
<div className="posts-section">
<h2 className="posts-heading">Recent Posts</h2>
{posts.length === 0 ? (
<div className="no-posts">
<p>No posts yet</p>
<span className="no-posts-icon">📝</span>
</div>
) : (
<ul className="posts-list" role="list">
{posts.map((post) => (
<li
key={post.id}
className={`post-item ${deletingPostId === post.id ? 'deleting' : ''}`}
role="article"
>
<div className="post-header">
<h3 className="post-title">{post.title}</h3>
<button
onClick={() => handleDelete(post.id)}
className="delete-post-btn"
disabled={deletingPostId === post.id || isLoading}
aria-label={`Delete post: ${post.title}`}
title="Delete post"
>
{deletingPostId === post.id ? '⏳' : '🗑️'}
</button>
</div>
<p className="post-content">{post.content}</p>
<div className="post-footer">
<span className="post-date">
{formatDate(post.createdAt)}
</span>
{post.likeCount > 0 && (
<span className="post-likes">
❤️ {post.likeCount}
</span>
)}
{post.commentCount > 0 && (
<span className="post-comments">
💬 {post.commentCount}
</span>
)}
</div>
</li>
))}
</ul>
)}
</div>
);
};
export default PostList;
📁 Organized Directory Structure
Here's how the refactored code should be organized:
src/
├── containers/
│ └── UserProfileContainer.jsx # Data & logic layer
│
├── components/
│ ├── common/ # Reusable UI components
│ │ ├── LoadingSpinner.jsx
│ │ ├── LoadingSpinner.css
│ │ ├── ErrorMessage.jsx
│ │ └── ErrorMessage.css
│ │
│ ├── profile/ # Profile-specific components
│ │ ├── ProfileHeader.jsx
│ │ └── ProfileHeader.css
│ │
│ ├── post/ # Post-related components
│ │ ├── PostList.jsx
│ │ └── PostList.css
│ │
│ ├── UserProfilePresenter.jsx # Main presenter
│ └── UserProfilePresenter.css
│
├── hooks/ # Custom hooks (if needed)
│ └── useUserProfile.js
│
├── services/ # API service layer
│ └── userService.js
│
└── App.jsx
Alternative: Feature-Based Structure
For larger applications, consider grouping by feature:
src/
├── features/
│ └── user-profile/
│ ├── containers/
│ │ └── UserProfileContainer.jsx
│ ├── components/
│ │ ├── UserProfilePresenter.jsx
│ │ ├── ProfileHeader.jsx
│ │ └── PostList.jsx
│ ├── hooks/
│ │ └── useUserProfile.js
│ └── services/
│ └── userProfileService.js
│
└── components/
└── common/ # Shared across features
├── LoadingSpinner.jsx
└── ErrorMessage.jsx
✅ Benefits of This Refactoring
1. Dramatically Improved Testability
Testing the Container:
// __tests__/UserProfileContainer.test.jsx
import { render, waitFor } from '@testing-library/react';
import axios from 'axios';
import UserProfileContainer from '../UserProfileContainer';
jest.mock('axios');
test('fetches and passes user data to presenter', async () => {
const mockUser = { id: 1, name: 'John Doe', email: 'john@example.com' };
axios.get.mockResolvedValue({ data: mockUser });
const { getByText } = render(<UserProfileContainer userId={1} />);
await waitFor(() => {
expect(axios.get).toHaveBeenCalledWith('/api/users/1');
});
});
Testing the Presenter:
// __tests__/UserProfilePresenter.test.jsx
import { render, fireEvent } from '@testing-library/react';
import UserProfilePresenter from '../UserProfilePresenter';
test('renders user information correctly', () => {
const mockUser = { name: 'Jane Doe', email: 'jane@example.com', bio: 'Developer' };
const { getByText } = render(
<UserProfilePresenter
user={mockUser}
posts={[]}
loading={false}
error={null}
onRetry={jest.fn()}
onUpdateUser={jest.fn()}
/>
);
expect(getByText('Jane Doe')).toBeInTheDocument();
expect(getByText('jane@example.com')).toBeInTheDocument();
});
test('enters edit mode when edit button is clicked', () => {
const mockUser = { name: 'Jane Doe', email: 'jane@example.com' };
const { getByLabelText } = render(
<UserProfilePresenter
user={mockUser}
posts={[]}
loading={false}
error={null}
onRetry={jest.fn()}
onUpdateUser={jest.fn()}
/>
);
fireEvent.click(getByLabelText('Edit profile'));
expect(getByLabelText('Edit profile form')).toBeInTheDocument();
});
2. Effortless Reusability
The LoadingSpinner
and ErrorMessage
components can now be used anywhere:
// In ProductList.jsx
<LoadingSpinner message="Loading products..." size="large" />
// In Dashboard.jsx
<ErrorMessage title="Connection Lost" message="Check your internet" />
3. Simplified Maintenance
Need to change how loading works? Update LoadingSpinner.jsx
.
Need to add a new user field? Modify ProfileHeader.jsx
.
Each change is isolated and predictable.
4. Better Performance Optimization
// Easy to memoize presenters
import { memo } from 'react';
const ProfileHeader = memo(({ user, isEditing, ...props }) => {
// Component renders only when props actually change
return (/* JSX */);
});
// Optimize callbacks in containers
import { useCallback } from 'react';
const handleUpdateUser = useCallback(async (formData) => {
// This function reference stays stable across renders
const response = await axios.put(`/api/users/${userId}`, formData);
setUser(response.data);
}, [userId]);
5. Enhanced Developer Experience
-
Faster onboarding: New developers can understand
ProfileHeader.jsx
in minutes - Parallel development: One developer works on the container, another on the presenter
- Clear git history: Changes to logic and UI are in separate files
- Better code reviews: Reviewers can focus on either data flow or UI presentation
6. Storybook Integration
Presenters work perfectly with design systems:
// ProfileHeader.stories.jsx
export default {
title: 'Profile/ProfileHeader',
component: ProfileHeader,
};
export const Default = {
args: {
user: { name: 'John Doe', email: 'john@example.com', bio: 'Developer' },
isEditing: false,
onStartEdit: () => console.log('Edit clicked'),
},
};
export const EditMode = {
args: {
user: { name: 'John Doe', email: 'john@example.com' },
isEditing: true,
formData: { name: 'John Doe', email: 'john@example.com', bio: '' },
onSaveProfile: () => console.log('Save clicked'),
},
};
🎓 Advanced Patterns & Variations
Pattern 1: Custom Hooks for Shared Logic
You can extract container logic into reusable hooks:
// hooks/useUserProfile.js
import { useState, useEffect, useCallback } from 'react';
import axios from 'axios';
export const useUserProfile = (userId) => {
const [user, setUser] = useState(null);
const [posts, setPosts] = useState([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
const fetchUserData = useCallback(async () => {
try {
setLoading(true);
setError(null);
const response = await axios.get(`/api/users/${userId}`);
setUser(response.data);
} catch (err) {
setError(err.message);
} finally {
setLoading(false);
}
}, [userId]);
const updateUser = useCallback(async (formData) => {
try {
const response = await axios.put(`/api/users/${userId}`, formData);
setUser(response.data);
return { success: true };
} catch (err) {
return { success: false, error: err.message };
}
}, [userId]);
useEffect(() => {
if (userId) {
fetchUserData();
}
}, [userId, fetchUserData]);
return {
user,
posts,
loading,
error,
updateUser,
refetch: fetchUserData,
};
};
// Then in your container:
const UserProfileContainer = ({ userId }) => {
const { user, posts, loading, error, updateUser, refetch } = useUserProfile(userId);
return (
<UserProfilePresenter
user={user}
posts={posts}
loading={loading}
error={error}
onUpdateUser={updateUser}
onRetry={refetch}
/>
);
};
Pattern 2: Service Layer for API Calls
Create a dedicated service for all user-related API operations:
// services/userService.js
import axios from 'axios';
const API_BASE = '/api';
export const userService = {
getUser: async (userId) => {
const response = await axios.get(`${API_BASE}/users/${userId}`);
return response.data;
},
updateUser: async (userId, data) => {
const response = await axios.put(`${API_BASE}/users/${userId}`, data);
return response.data;
},
getUserPosts: async (userId) => {
const response = await axios.get(`${API_BASE}/posts?userId=${userId}`);
return response.data;
},
deletePost: async (postId) => {
await axios.delete(`${API_BASE}/posts/${postId}`);
},
};
// Usage in container:
import { userService } from '../services/userService';
const fetchUserData = async () => {
try {
setLoading(true);
const userData = await userService.getUser(userId);
setUser(userData);
} catch (err) {
setError(err.message);
} finally {
setLoading(false);
}
};
Pattern 3: Compound Components for Complex UIs
For very complex presenters, break them into compound components:
// components/profile/ProfileCard.jsx
const ProfileCard = ({ children }) => {
return <div className="profile-card">{children}</div>;
};
ProfileCard.Avatar = ({ src, alt, status }) => (
<div className="profile-avatar">
<img src={src} alt={alt} />
<span className={`status ${status}`}></span>
</div>
);
ProfileCard.Info = ({ children }) => (
<div className="profile-info">{children}</div>
);
ProfileCard.Stats = ({ followers, following, posts }) => (
<div className="profile-stats">
<div className="stat">
<span>{followers}</span>
<label>Followers</label>
</div>
<div className="stat">
<span>{following}</span>
<label>Following</label>
</div>
<div className="stat">
<span>{posts}</span>
<label>Posts</label>
</div>
</div>
);
// Usage:
<ProfileCard>
<ProfileCard.Avatar src={user.avatar} alt={user.name} status="online" />
<ProfileCard.Info>
<h1>{user.name}</h1>
<p>{user.email}</p>
</ProfileCard.Info>
<ProfileCard.Stats
followers={user.followerCount}
following={user.followingCount}
posts={user.postCount}
/>
</ProfileCard>
⚖️ When to Use (and When NOT to Use)
✅ Ideal Use Cases
1. Data-Heavy Components
- Dashboards displaying analytics from multiple API endpoints
- User profiles with complex data relationships
- Product catalogs with filtering, sorting, and pagination
- Admin panels with CRUD operations
2. Components with Complex State Management
- Multi-step forms (wizards, checkouts)
- Settings pages with multiple sections
- Interactive data tables with sorting, filtering, searching
- Real-time data displays (chat, notifications, live scores)
3. Components Requiring High Reusability
- Design system components used across multiple features
- Components shared between web and mobile (React Native)
- White-label applications with multiple themes
- Components showcased in Storybook or style guides
4. Team Collaboration Scenarios
- Backend developers can work on containers while frontend developers handle presenters
- Designers can iterate on presenters without touching business logic
- Multiple teams working on the same feature simultaneously
⚠️ When NOT to Use
1. Simple, Static Components
// ❌ OVERKILL: Don't split this
const Button = ({ children, onClick }) => (
<button onClick={onClick}>{children}</button>
);
// ❌ UNNECESSARY: No business logic here
const Badge = ({ text, color }) => (
<span className={`badge badge-${color}`}>{text}</span>
);
2. Deeply Nested Component Hierarchies
If you find yourself passing props through 4+ component levels, consider alternatives:
// ❌ PROP DRILLING NIGHTMARE
<Container> // Has the data
<Presenter> // Passes it down
<Section> // Passes it down
<SubSection> // Passes it down
<Item> // Finally uses it
Better alternatives:
- Context API for truly global state (theme, auth, language)
- State management libraries (Redux, Zustand, Jotai) for complex shared state
- Composition to avoid deep nesting
3. Performance-Critical Rendering
For lists with thousands of items or real-time animations:
// ❌ Additional component layer may impact performance
{items.map(item => (
<ItemContainer key={item.id} data={item}>
<ItemPresenter />
</ItemContainer>
))}
// ✅ Better: Keep it flat for virtual lists
{items.map(item => (
<Item key={item.id} {...item} />
))}
4. One-Off Prototypes
When you're rapidly prototyping or building a proof-of-concept, premature abstraction slows you down. Build first, refactor later when patterns emerge.
🎯 Practical Exercise: Build Your Own
To truly master this pattern, let's build a Product List feature from scratch.
Requirements Specification
Build a product catalog page with the following features:
Data Requirements
- Fetch products from
https://fakestoreapi.com/products
- Each product has: id, title, price, description, category, image, rating
Functionality
- Display all products in a grid layout
- Filter by category (electronics, jewelry, men's clothing, women's clothing)
- Sort by price (low to high, high to low)
- Search by product name
- Add/remove products to/from cart
- Show loading state while fetching
- Handle and display errors with retry option
- Display cart summary (total items, total price)
Step 1: Plan Your Architecture
Before coding, map out your components:
ProductPageContainer (Container)
├── Manages: products state, cart state, loading, error
├── Handles: fetching, filtering, sorting, cart operations
└── Renders: ProductPagePresenter
ProductPagePresenter (Presenter)
├── SearchBar
├── FilterControls
├── SortControls
├── ProductGrid
│ └── ProductCard (for each product)
└── CartSummary
Step 2: Implement the Container
// containers/ProductPageContainer.jsx
import { useState, useEffect } from 'react';
import axios from 'axios';
import ProductPagePresenter from '../components/ProductPagePresenter';
const ProductPageContainer = () => {
const [products, setProducts] = useState([]);
const [filteredProducts, setFilteredProducts] = useState([]);
const [cart, setCart] = useState([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
const [categories, setCategories] = useState([]);
// Fetch products
const fetchProducts = async () => {
try {
setLoading(true);
setError(null);
const [productsRes, categoriesRes] = await Promise.all([
axios.get('https://fakestoreapi.com/products'),
axios.get('https://fakestoreapi.com/products/categories')
]);
setProducts(productsRes.data);
setFilteredProducts(productsRes.data);
setCategories(categoriesRes.data);
} catch (err) {
setError('Failed to load products. Please try again.');
console.error(err);
} finally {
setLoading(false);
}
};
// Filter by category
const handleFilterByCategory = (category) => {
if (category === 'all') {
setFilteredProducts(products);
} else {
setFilteredProducts(products.filter(p => p.category === category));
}
};
// Sort products
const handleSort = (sortType) => {
const sorted = [...filteredProducts];
if (sortType === 'price-low') {
sorted.sort((a, b) => a.price - b.price);
} else if (sortType === 'price-high') {
sorted.sort((a, b) => b.price - a.price);
} else if (sortType === 'rating') {
sorted.sort((a, b) => b.rating.rate - a.rating.rate);
}
setFilteredProducts(sorted);
};
// Search products
const handleSearch = (query) => {
if (!query.trim()) {
setFilteredProducts(products);
return;
}
const filtered = products.filter(product =>
product.title.toLowerCase().includes(query.toLowerCase()) ||
product.description.toLowerCase().includes(query.toLowerCase())
);
setFilteredProducts(filtered);
};
// Cart operations
const handleAddToCart = (product) => {
setCart(prevCart => {
const existingItem = prevCart.find(item => item.id === product.id);
if (existingItem) {
return prevCart.map(item =>
item.id === product.id
? { ...item, quantity: item.quantity + 1 }
: item
);
}
return [...prevCart, { ...product, quantity: 1 }];
});
};
const handleRemoveFromCart = (productId) => {
setCart(prevCart => prevCart.filter(item => item.id !== productId));
};
const handleUpdateQuantity = (productId, quantity) => {
if (quantity <= 0) {
handleRemoveFromCart(productId);
return;
}
setCart(prevCart =>
prevCart.map(item =>
item.id === productId ? { ...item, quantity } : item
)
);
};
useEffect(() => {
fetchProducts();
}, []);
return (
<ProductPagePresenter
products={filteredProducts}
cart={cart}
categories={categories}
loading={loading}
error={error}
onRetry={fetchProducts}
onFilterByCategory={handleFilterByCategory}
onSort={handleSort}
onSearch={handleSearch}
onAddToCart={handleAddToCart}
onRemoveFromCart={handleRemoveFromCart}
onUpdateQuantity={handleUpdateQuantity}
/>
);
};
export default ProductPageContainer;
Step 3: Implement the Presenter
// components/ProductPagePresenter.jsx
import { useState } from 'react';
import LoadingSpinner from './common/LoadingSpinner';
import ErrorMessage from './common/ErrorMessage';
import SearchBar from './products/SearchBar';
import FilterControls from './products/FilterControls';
import SortControls from './products/SortControls';
import ProductGrid from './products/ProductGrid';
import CartSummary from './products/CartSummary';
import './ProductPagePresenter.css';
const ProductPagePresenter = ({
products,
cart,
categories,
loading,
error,
onRetry,
onFilterByCategory,
onSort,
onSearch,
onAddToCart,
onRemoveFromCart,
onUpdateQuantity
}) => {
const [showCart, setShowCart] = useState(false);
if (loading) {
return <LoadingSpinner message="Loading products..." />;
}
if (error) {
return <ErrorMessage message={error} onRetry={onRetry} />;
}
const cartItemCount = cart.reduce((sum, item) => sum + item.quantity, 0);
const cartTotal = cart.reduce((sum, item) => sum + (item.price * item.quantity), 0);
return (
<div className="product-page">
<header className="page-header">
<h1>Product Catalog</h1>
<button
className="cart-toggle"
onClick={() => setShowCart(!showCart)}
aria-label="Toggle cart"
>
🛒 Cart ({cartItemCount})
</button>
</header>
<div className="controls-section">
<SearchBar onSearch={onSearch} />
<div className="filters-sorts">
<FilterControls
categories={categories}
onFilterByCategory={onFilterByCategory}
/>
<SortControls onSort={onSort} />
</div>
</div>
<div className="content-section">
<ProductGrid
products={products}
onAddToCart={onAddToCart}
cart={cart}
/>
{showCart && (
<CartSummary
cart={cart}
total={cartTotal}
onRemoveFromCart={onRemoveFromCart}
onUpdateQuantity={onUpdateQuantity}
onClose={() => setShowCart(false)}
/>
)}
</div>
{products.length === 0 && (
<div className="no-products">
<p>No products found matching your criteria</p>
</div>
)}
</div>
);
};
export default ProductPagePresenter;
Step 4: Create Supporting Components
// components/products/SearchBar.jsx
import { useState } from 'react';
import './SearchBar.css';
const SearchBar = ({ onSearch }) => {
const [query, setQuery] = useState('');
const handleSubmit = (e) => {
e.preventDefault();
onSearch(query);
};
return (
<form className="search-bar" onSubmit={handleSubmit}>
<input
type="text"
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Search products..."
className="search-input"
/>
<button type="submit" className="search-button">
🔍 Search
</button>
</form>
);
};
export default SearchBar;
// components/products/ProductGrid.jsx
import ProductCard from './ProductCard';
import './ProductGrid.css';
const ProductGrid = ({ products, onAddToCart, cart }) => {
return (
<div className="product-grid">
{products.map(product => {
const cartItem = cart.find(item => item.id === product.id);
const inCart = !!cartItem;
const quantity = cartItem?.quantity || 0;
return (
<ProductCard
key={product.id}
product={product}
onAddToCart={onAddToCart}
inCart={inCart}
quantity={quantity}
/>
);
})}
</div>
);
};
export default ProductGrid;
// components/products/ProductCard.jsx
import './ProductCard.css';
const ProductCard = ({ product, onAddToCart, inCart, quantity }) => {
return (
<div className="product-card">
<div className="product-image">
<img src={product.image} alt={product.title} />
{inCart && <span className="in-cart-badge">{quantity} in cart</span>}
</div>
<div className="product-details">
<h3 className="product-title">{product.title}</h3>
<p className="product-category">{product.category}</p>
<div className="product-rating">
⭐ {product.rating.rate} ({product.rating.count} reviews)
</div>
<div className="product-footer">
<span className="product-price">${product.price.toFixed(2)}</span>
<button
className="add-to-cart-btn"
onClick={() => onAddToCart(product)}
>
{inCart ? '+ Add More' : '🛒 Add to Cart'}
</button>
</div>
</div>
</div>
);
};
export default ProductCard;
🚀 Taking It Further: Next Steps
Level Up Your Skills
- Add TypeScript: Type your props for better IDE support and error catching
- Implement React Query: Replace manual data fetching with a powerful caching solution
- Add Unit Tests: Write comprehensive tests for both containers and presenters
- Build a Storybook: Showcase all your presenter components
- Performance Optimization: Add memoization, lazy loading, and code splitting
Advanced Challenges
- Add pagination or infinite scroll
- Implement wishlist functionality
- Add product comparison feature
- Build an admin dashboard to manage products
- Integrate real payment processing
- Add user authentication and order history
💭 Final Thoughts
The Container-Presenter pattern is more than a coding technique—it's a mindset about separating concerns and building maintainable software. When you think in layers (data vs. presentation), you naturally write cleaner, more testable code.
Key Takeaways
✅ Containers handle data and logic - No UI concerns
✅ Presenters handle UI and interactions - No data fetching
✅ Sub-components are small and focused - Single responsibility
✅ Reusability emerges naturally - Components become portable
✅ Testing becomes straightforward - Mock data, not APIs
✅ Team collaboration improves - Clear boundaries of responsibility
The Pattern Evolution
As you grow as a React developer, you'll discover that Container-Presenter forms the foundation for more advanced patterns:
- Render Props and Higher-Order Components extend container logic
- Compound Components enhance presenter flexibility
- Context API solves prop drilling in containers
- Custom Hooks extract reusable container logic
Start with Container-Presenter. Master it. Then evolve your architecture as your applications demand.
🔗 Resources & Further Reading
- React Documentation - Thinking in React
- Martin Fowler - Presentation Domain Separation
- Testing Library - Best Practices
- Storybook Documentation
Ready to build better React applications? Start refactoring today! 🚀
Your code—and your team—will thank you.
Share your implementations! Join the conversation:
- Tweet your refactored components with #ReactContainerPresenter
- Share your code on GitHub
- Get feedback in the Tapascript Discord
Happy coding! ⚛️
Top comments (0)