PatternsArchitecture Patterns
Container/Presenter Pattern
Divide components into Smart (handle data) and Dumb (handle only visual) - separation of concerns
The Container/Presenter Pattern divides components into two types: Container (Smart) that handle data and logic, and Presenter (Dumb) that handle only the visual. Although Hooks have changed this, separation of concerns is still vital.
🎯 The Concept
// ❌ BAD: Everything mixed
function UserProfile({ userId }: { userId: string }) {
const [user, setUser] = useState(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
fetch(`/api/users/${userId}`)
.then(res => res.json())
.then(setUser)
.finally(() => setLoading(false));
}, [userId]);
if (loading) return <div>Loading...</div>;
return (
<div className="profile">
<img src={user.avatar} alt={user.name} />
<h1>{user.name}</h1>
<p>{user.bio}</p>
</div>
);
}
// ✅ GOOD: Container/Presenter
// Container (Smart) - handles data
function UserProfileContainer({ userId }: { userId: string }) {
const [user, setUser] = useState(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
fetch(`/api/users/${userId}`)
.then(res => res.json())
.then(setUser)
.finally(() => setLoading(false));
}, [userId]);
return <UserProfilePresenter user={user} loading={loading} />;
}
// Presenter (Dumb) - handles only visual
function UserProfilePresenter({
user,
loading
}: {
user: User | null;
loading: boolean;
}) {
if (loading) return <div>Loading...</div>;
if (!user) return <div>User not found</div>;
return (
<div className="profile">
<img src={user.avatar} alt={user.name} />
<h1>{user.name}</h1>
<p>{user.bio}</p>
</div>
);
}📚 Common Front-End Examples
1. User List
// Container - fetches data
function UserListContainer() {
const [users, setUsers] = useState<User[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<Error | null>(null);
useEffect(() => {
fetch('/api/users')
.then(res => res.json())
.then(setUsers)
.catch(setError)
.finally(() => setLoading(false));
}, []);
return (
<UserListPresenter
users={users}
loading={loading}
error={error}
/>
);
}
// Presenter - only renders
function UserListPresenter({
users,
loading,
error
}: {
users: User[];
loading: boolean;
error: Error | null;
}) {
if (loading) return <Spinner />;
if (error) return <ErrorMessage error={error} />;
return (
<ul>
{users.map(user => (
<li key={user.id}>
<UserCard user={user} />
</li>
))}
</ul>
);
}2. Form with Validation
import { useState } from 'react';
// Container - validation and submit logic
async function login(credentials: { email: string; password: string }): Promise<void> {
const res = await fetch('/api/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(credentials)
});
if (!res.ok) throw new Error('Login failed');
}
function LoginFormContainer() {
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [errors, setErrors] = useState<Record<string, string>>({});
const [loading, setLoading] = useState(false);
const validate = () => {
const newErrors: Record<string, string> = {};
if (!email) newErrors.email = 'Email is required';
if (!email.includes('@')) newErrors.email = 'Invalid email';
if (!password) newErrors.password = 'Password is required';
if (password.length < 6) newErrors.password = 'Password too short';
setErrors(newErrors);
return Object.keys(newErrors).length === 0;
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!validate()) return;
setLoading(true);
try {
await login({ email, password });
} catch (error) {
setErrors({ submit: 'Login error' });
} finally {
setLoading(false);
}
};
return (
<LoginFormPresenter
email={email}
password={password}
errors={errors}
loading={loading}
onEmailChange={setEmail}
onPasswordChange={setPassword}
onSubmit={handleSubmit}
/>
);
}
// Presenter - only UI
function LoginFormPresenter({
email,
password,
errors,
loading,
onEmailChange,
onPasswordChange,
onSubmit
}: {
email: string;
password: string;
errors: Record<string, string>;
loading: boolean;
onEmailChange: (value: string) => void;
onPasswordChange: (value: string) => void;
onSubmit: (e: React.FormEvent) => void;
}) {
return (
<form onSubmit={onSubmit}>
<div>
<input
type="email"
value={email}
onChange={(e) => onEmailChange(e.target.value)}
/>
{errors.email && <span>{errors.email}</span>}
</div>
<div>
<input
type="password"
value={password}
onChange={(e) => onPasswordChange(e.target.value)}
/>
{errors.password && <span>{errors.password}</span>}
</div>
{errors.submit && <div>{errors.submit}</div>}
<button type="submit" disabled={loading}>
{loading ? 'Logging in...' : 'Login'}
</button>
</form>
);
}3. Dashboard with Multiple Data Sources
import { useState, useEffect } from 'react';
interface Stats {
totalUsers: number;
totalOrders: number;
}
interface Activity {
id: string;
action: string;
timestamp: string;
}
// Container - fetches multiple data sources
function DashboardContainer() {
const [stats, setStats] = useState<Stats | null>(null);
const [recentActivity, setRecentActivity] = useState<Activity[]>([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
Promise.all([
fetch('/api/stats').then(res => res.json()),
fetch('/api/activity').then(res => res.json())
]).then(([statsData, activityData]) => {
setStats(statsData);
setRecentActivity(activityData);
}).finally(() => setLoading(false));
}, []);
return (
<DashboardPresenter
stats={stats}
recentActivity={recentActivity}
loading={loading}
/>
);
}
// Presenter - only layout
function DashboardPresenter({
stats,
recentActivity,
loading
}: {
stats: Stats | null;
recentActivity: Activity[];
loading: boolean;
}) {
if (loading) return <Spinner />;
return (
<div className="dashboard">
<StatsCards stats={stats} />
<RecentActivityList activities={recentActivity} />
</div>
);
}4. With Custom Hooks
import { useState, useEffect } from 'react';
interface User {
id: string;
name: string;
avatar: string;
bio: string;
}
// Custom hook - reusable logic
function useUserData(userId: string) {
const [user, setUser] = useState<User | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<Error | null>(null);
useEffect(() => {
fetch(`/api/users/${userId}`)
.then(res => res.json())
.then(setUser)
.catch(setError)
.finally(() => setLoading(false));
}, [userId]);
return { user, loading, error };
}
// Container - uses hook
function UserProfileContainer({ userId }: { userId: string }) {
const { user, loading, error } = useUserData(userId);
return (
<UserProfilePresenter
user={user}
loading={loading}
error={error}
/>
);
}
// Presenter - pure, testable
function UserProfilePresenter({
user,
loading,
error
}: {
user: User | null;
loading: boolean;
error: Error | null;
}) {
if (loading) return <Spinner />;
if (error) return <ErrorMessage error={error} />;
if (!user) return <div>User not found</div>;
return (
<div className="profile">
<img src={user.avatar} alt={user.name} />
<h1>{user.name}</h1>
<p>{user.bio}</p>
</div>
);
}5. Data Table with Filters
import { useState, useEffect } from 'react';
interface Item {
id: string;
name: string;
date: string;
}
// Container - filter and pagination logic
function DataTableContainer() {
const [data, setData] = useState<Item[]>([]);
const [filteredData, setFilteredData] = useState<Item[]>([]);
const [searchTerm, setSearchTerm] = useState('');
const [sortBy, setSortBy] = useState<keyof Item>('name');
const [sortOrder, setSortOrder] = useState<'asc' | 'desc'>('asc');
const [currentPage, setCurrentPage] = useState(1);
const pageSize = 10;
useEffect(() => {
fetch('/api/items')
.then(res => res.json())
.then(setData);
}, []);
useEffect(() => {
let filtered = [...data];
// Filter
if (searchTerm) {
filtered = filtered.filter(item =>
item.name.toLowerCase().includes(searchTerm.toLowerCase())
);
}
// Sort
filtered.sort((a, b) => {
const aVal = a[sortBy];
const bVal = b[sortBy];
const comparison = aVal > bVal ? 1 : aVal < bVal ? -1 : 0;
return sortOrder === 'asc' ? comparison : -comparison;
});
// Paginate
const start = (currentPage - 1) * pageSize;
const paginated = filtered.slice(start, start + pageSize);
setFilteredData(paginated);
}, [data, searchTerm, sortBy, sortOrder, currentPage]);
return (
<DataTablePresenter
data={filteredData}
searchTerm={searchTerm}
sortBy={sortBy}
sortOrder={sortOrder}
currentPage={currentPage}
totalPages={Math.ceil(data.length / pageSize)}
onSearchChange={setSearchTerm}
onSortChange={setSortBy}
onSortOrderChange={setSortOrder}
onPageChange={setCurrentPage}
/>
);
}
// Presenter - only rendering
function DataTablePresenter({
data,
searchTerm,
sortBy,
sortOrder,
currentPage,
totalPages,
onSearchChange,
onSortChange,
onSortOrderChange,
onPageChange
}: {
data: Item[];
searchTerm: string;
sortBy: keyof Item;
sortOrder: 'asc' | 'desc';
currentPage: number;
totalPages: number;
onSearchChange: (value: string) => void;
onSortChange: (field: keyof Item) => void;
onSortOrderChange: (order: 'asc' | 'desc') => void;
onPageChange: (page: number) => void;
}) {
return (
<div>
<input
value={searchTerm}
onChange={(e) => onSearchChange(e.target.value)}
placeholder="Search..."
/>
<table>
<thead>
<tr>
<th onClick={() => onSortChange('name')}>
Name {sortBy === 'name' && (sortOrder === 'asc' ? '↑' : '↓')}
</th>
<th onClick={() => onSortChange('date')}>
Date {sortBy === 'date' && (sortOrder === 'asc' ? '↑' : '↓')}
</th>
</tr>
</thead>
<tbody>
{data.map(item => (
<tr key={item.id}>
<td>{item.name}</td>
<td>{item.date}</td>
</tr>
))}
</tbody>
</table>
<div>
<button
onClick={() => onPageChange(currentPage - 1)}
disabled={currentPage === 1}
>
Previous
</button>
<span>Page {currentPage} of {totalPages}</span>
<button
onClick={() => onPageChange(currentPage + 1)}
disabled={currentPage === totalPages}
>
Next
</button>
</div>
</div>
);
}
// Usage
function App() {
return <DataTableContainer />;
}🎯 When to Use
This pattern is commonly used and recommended for:
- Components with complex logic - Separate data fetching from rendering
- UI reuse - Same Presenter can be used with different Containers
- Testability - Test logic (Container) and UI (Presenter) separately
- Team collaboration - Designers work on Presenters, devs on Containers
- Refactoring - Change logic without affecting UI and vice versa
🔄 Evolution with Hooks
With Hooks, the pattern evolved:
// Before: Separate Container/Presenter
function Container() { /* logic */ }
function Presenter() { /* UI */ }
// Now: Hook + Component
function useData() { /* logic */ }
function Component() {
const data = useData();
return /* UI */;
}But separation is still useful for:
- Very complex components
- UI reuse
- Testability
📚 Key Takeaways
- Separation of concerns - Logic vs Visual
- Testability - Test each part in isolation
- Reusability - Same Presenter with different Containers
- Maintainability - Isolated changes don't affect other parts
- Collaboration - Designers and devs work on different parts