Episode 2 — React Frontend Architecture NextJS / 2.6 — Component Architecture Principles
2.6.b — Smart vs Dumb Components
In one sentence: Separate components that know things (data fetching, state, side effects) from components that show things (pure UI rendering) to maximize reusability and testability.
Navigation: ← Single Responsibility Principle · Next → Lifting State Up
Table of Contents
- The Core Idea
- History and Naming
- Presentational (Dumb) Components
- Container (Smart) Components
- Side-by-Side Comparison
- Case Study: User Profile
- Case Study: Shopping Cart
- The Modern Hooks Approach
- When the Pattern Still Matters
- Testing Benefits
- Storybook and Design Systems
- Common Mistakes
- Decision Framework
- Real-World File Organization
- Key Takeaways
1. The Core Idea
Every piece of UI involves two concerns:
- Where does the data come from? (APIs, state, context, URL params)
- How does the data look on screen? (HTML, CSS, layout)
When you mix these two concerns in one component, you get:
- Hard-to-test components (need to mock APIs just to test layout)
- Hard-to-reuse components (tied to specific data sources)
- Hard-to-maintain components (UI changes tangled with data changes)
The solution: separate them into two types of components.
┌───────────────────────────────────────────────────┐
│ │
│ SMART (Container) DUMB (Presentational)│
│ ───────────────── ──────────────────── │
│ │
│ "WHERE data comes from" "HOW data looks" │
│ │
│ ┌──────────────────┐ ┌──────────────────┐ │
│ │ • Fetches data │ │ • Receives props │ │
│ │ • Manages state │ ───► │ • Renders UI │ │
│ │ • Calls APIs │ data │ • Handles layout │ │
│ │ • Handles errors │ │ • Applies styles │ │
│ │ • Coordinates │ │ • Fires callbacks│ │
│ └──────────────────┘ └──────────────────┘ │
│ │
│ Has side effects Zero side effects │
│ Hard to test Easy to test │
│ Not reusable Very reusable │
│ Few or no DOM elements DOM-heavy │
│ │
└───────────────────────────────────────────────────┘
2. History and Naming
Dan Abramov's Original Pattern (2015)
Dan Abramov (co-creator of Redux, member of React team) popularized this pattern in his article "Presentational and Container Components." He used the terms:
- Presentational Components ("dumb") — concerned with how things look
- Container Components ("smart") — concerned with how things work
The 2019 Update
In 2019, Dan Abramov added a note to his article:
"I don't suggest splitting your components like this anymore. I wrote this article as a pattern I found useful, but I've since moved on from it. Hooks let you separate concerns without arbitrary divisions."
Does This Mean the Pattern Is Dead?
No. The strict separation into two files is less necessary with hooks. But the underlying principle — separating data logic from presentation — is more relevant than ever.
| Era | How separation is achieved |
|---|---|
| 2015–2018 | Container component (class) + Presentational component (function) |
| 2019–2022 | Custom hook (data) + Component (UI) |
| 2023+ | Server Component (data) + Client Component (UI) |
The principle stays the same. The implementation evolves.
Modern Terminology
| Classic Term | Modern Equivalent | Description |
|---|---|---|
| Container / Smart | "Data component" or custom hook | Handles data, state, effects |
| Presentational / Dumb | "UI component" | Pure rendering, props only |
We'll use both classic and modern terms throughout this file.
3. Presentational (Dumb) Components
Characteristics
A presentational component:
- Receives all data via props — never fetches its own data
- Has no side effects — no useEffect, no API calls, no localStorage
- May have internal UI state — things like
isOpen,activeTab,hoveredIndex - Renders DOM elements — the actual HTML/CSS
- Calls callback props —
onClick,onSubmit,onChange— but doesn't know what happens - Is highly reusable — works with any data source
Examples
// ✅ Pure presentational: receives everything via props
function UserCard({ name, avatar, role, onEdit, onDelete }) {
return (
<div className="card">
<img src={avatar} alt={name} className="avatar" />
<div className="info">
<h3>{name}</h3>
<span className="badge">{role}</span>
</div>
<div className="actions">
<button onClick={onEdit}>Edit</button>
<button onClick={onDelete}>Delete</button>
</div>
</div>
);
}
// ✅ Has internal UI state but no data logic
function Accordion({ title, children }) {
const [isOpen, setIsOpen] = useState(false);
return (
<div className="accordion">
<button onClick={() => setIsOpen(!isOpen)}>
{title} {isOpen ? '▼' : '▶'}
</button>
{isOpen && <div className="content">{children}</div>}
</div>
);
}
// ✅ Complex presentational component — still no data fetching
function DataTable({ columns, rows, sortColumn, sortDirection, onSort, onRowClick }) {
return (
<table>
<thead>
<tr>
{columns.map(col => (
<th key={col.key} onClick={() => onSort(col.key)}>
{col.label}
{sortColumn === col.key && (sortDirection === 'asc' ? ' ↑' : ' ↓')}
</th>
))}
</tr>
</thead>
<tbody>
{rows.map(row => (
<tr key={row.id} onClick={() => onRowClick(row)}>
{columns.map(col => (
<td key={col.key}>{row[col.key]}</td>
))}
</tr>
))}
</tbody>
</table>
);
}
What UI State Is Allowed?
Presentational components CAN have state for:
| Allowed (UI state) | Not Allowed (Data state) |
|---|---|
isOpen / isExpanded | users from API |
activeTab | currentUser from auth |
hoveredIndex | cartItems from server |
inputValue (before submit) | notifications from WebSocket |
animationPhase | searchResults from API |
tooltipPosition | formErrors from validation |
Rule of thumb: If the state disappears when the component unmounts and nobody else cares, it's UI state. If other components need it or it persists across navigation, it's data state.
4. Container (Smart) Components
Characteristics
A container component:
- Fetches data — from APIs, context, URL params, localStorage
- Manages state — loading, error, data transformations
- Has side effects — useEffect, subscriptions, analytics
- Renders minimal or no DOM — mostly renders child components
- Passes data down as props — bridges data sources to UI components
- Handles user actions — receives callbacks from children, performs operations
Examples
// ✅ Container: all about data, minimal DOM
function UserListContainer() {
const [users, setUsers] = useState([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
const [search, setSearch] = useState('');
useEffect(() => {
setLoading(true);
fetch('/api/users')
.then(r => r.json())
.then(setUsers)
.catch(setError)
.finally(() => setLoading(false));
}, []);
const filtered = useMemo(
() => users.filter(u => u.name.toLowerCase().includes(search.toLowerCase())),
[users, search]
);
const handleDelete = async (userId) => {
await fetch(`/api/users/${userId}`, { method: 'DELETE' });
setUsers(prev => prev.filter(u => u.id !== userId));
};
if (loading) return <LoadingSpinner />;
if (error) return <ErrorMessage error={error} />;
return (
<UserList
users={filtered}
search={search}
onSearchChange={setSearch}
onDelete={handleDelete}
/>
);
}
// ✅ Container from context
function CartButtonContainer() {
const { items, removeItem, updateQuantity } = useCart();
const total = useMemo(
() => items.reduce((sum, i) => sum + i.price * i.quantity, 0),
[items]
);
return (
<CartButton
itemCount={items.length}
total={total}
items={items}
onRemove={removeItem}
onUpdateQuantity={updateQuantity}
/>
);
}
What Containers Should NOT Do
// ❌ Container with too much DOM
function UserListContainer() {
const { users, loading } = useUsers();
return (
<div className="p-4 bg-white rounded-lg shadow"> {/* ← Container doing layout */}
<h2 className="text-2xl font-bold mb-4">Users</h2> {/* ← Container doing presentation */}
<div className="grid grid-cols-3 gap-4"> {/* ← Container doing layout */}
{users.map(u => <UserCard key={u.id} user={u} />)}
</div>
</div>
);
}
// ✅ Container delegates ALL rendering
function UserListContainer() {
const { users, loading, error } = useUsers();
if (loading) return <UserListSkeleton />;
if (error) return <ErrorMessage error={error} />;
return <UserList users={users} />;
}
5. Side-by-Side Comparison
Comparison Table
| Aspect | Presentational (Dumb) | Container (Smart) |
|---|---|---|
| Purpose | How things look | How things work |
| Data source | Props only | API, context, hooks, URL |
| State | UI state only (isOpen, activeTab) | Data state (users, loading, error) |
| Side effects | None | useEffect, subscriptions |
| DOM output | HTML elements with styling | Minimal — renders child components |
| Reusable? | Yes — works with any data | No — tied to specific data source |
| Testable? | Very — pass props, check output | Harder — must mock data sources |
| Examples | Button, Card, UserList, Modal | UserListPage, CartContainer, AuthGate |
| Who creates? | UI/design team | Feature developers |
| Changes when | Design changes | Data/business requirements change |
Visual Comparison
// PRESENTATIONAL — "How does it look?"
function NotificationList({ notifications, onDismiss, onMarkRead }) {
if (notifications.length === 0) {
return <EmptyState message="No notifications" />;
}
return (
<ul className="notification-list">
{notifications.map(notif => (
<li key={notif.id} className={notif.read ? 'read' : 'unread'}>
<span>{notif.message}</span>
<span className="time">{formatTimeAgo(notif.createdAt)}</span>
<button onClick={() => onMarkRead(notif.id)}>✓</button>
<button onClick={() => onDismiss(notif.id)}>×</button>
</li>
))}
</ul>
);
}
// CONTAINER — "Where does the data come from?"
function NotificationListContainer() {
const { notifications, isLoading, markRead, dismiss } = useNotifications();
if (isLoading) return <NotificationListSkeleton count={5} />;
return (
<NotificationList
notifications={notifications}
onMarkRead={markRead}
onDismiss={dismiss}
/>
);
}
6. Case Study: User Profile
The Mixed Component (Before)
// ❌ Everything in one component
function UserProfile({ userId }) {
const [user, setUser] = useState(null);
const [posts, setPosts] = useState([]);
const [loading, setLoading] = useState(true);
const [activeTab, setActiveTab] = useState('posts');
useEffect(() => {
Promise.all([
fetch(`/api/users/${userId}`).then(r => r.json()),
fetch(`/api/users/${userId}/posts`).then(r => r.json()),
]).then(([userData, postsData]) => {
setUser(userData);
setPosts(postsData);
setLoading(false);
});
}, [userId]);
useEffect(() => {
analytics.track('profile_view', { userId });
}, [userId]);
const handleFollow = async () => {
await fetch(`/api/users/${userId}/follow`, { method: 'POST' });
setUser(prev => ({ ...prev, isFollowing: true, followers: prev.followers + 1 }));
};
if (loading) return <div>Loading...</div>;
return (
<div className="profile">
<div className="header">
<img src={user.avatar} alt={user.name} className="avatar-lg" />
<h1>{user.name}</h1>
<p>{user.bio}</p>
<div className="stats">
<span>{user.followers} followers</span>
<span>{user.following} following</span>
<span>{posts.length} posts</span>
</div>
{!user.isFollowing && (
<button onClick={handleFollow} className="btn-primary">Follow</button>
)}
</div>
<div className="tabs">
<button onClick={() => setActiveTab('posts')}
className={activeTab === 'posts' ? 'active' : ''}>Posts</button>
<button onClick={() => setActiveTab('likes')}
className={activeTab === 'likes' ? 'active' : ''}>Likes</button>
</div>
{activeTab === 'posts' && (
<div className="post-grid">
{posts.map(p => (
<div key={p.id} className="post-card">
<img src={p.image} alt="" />
<p>{p.caption}</p>
<span>{p.likes} likes</span>
</div>
))}
</div>
)}
</div>
);
}
The Split Version (After)
// === CUSTOM HOOKS (data layer) ===
// hooks/useUserProfile.js
function useUserProfile(userId) {
const [user, setUser] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
useEffect(() => {
setLoading(true);
fetch(`/api/users/${userId}`)
.then(r => {
if (!r.ok) throw new Error('Failed to load profile');
return r.json();
})
.then(setUser)
.catch(setError)
.finally(() => setLoading(false));
}, [userId]);
const follow = async () => {
await fetch(`/api/users/${userId}/follow`, { method: 'POST' });
setUser(prev => ({
...prev,
isFollowing: true,
followers: prev.followers + 1,
}));
};
return { user, loading, error, follow };
}
// hooks/useUserPosts.js
function useUserPosts(userId) {
const [posts, setPosts] = useState([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
fetch(`/api/users/${userId}/posts`)
.then(r => r.json())
.then(setPosts)
.finally(() => setLoading(false));
}, [userId]);
return { posts, loading };
}
// === PRESENTATIONAL COMPONENTS (UI layer) ===
// ProfileHeader — knows nothing about APIs
function ProfileHeader({ user, onFollow }) {
return (
<div className="header">
<img src={user.avatar} alt={user.name} className="avatar-lg" />
<h1>{user.name}</h1>
<p>{user.bio}</p>
<ProfileStats
followers={user.followers}
following={user.following}
posts={user.postCount}
/>
{!user.isFollowing && (
<button onClick={onFollow} className="btn-primary">Follow</button>
)}
</div>
);
}
function ProfileStats({ followers, following, posts }) {
return (
<div className="stats">
<StatItem label="followers" count={followers} />
<StatItem label="following" count={following} />
<StatItem label="posts" count={posts} />
</div>
);
}
function StatItem({ label, count }) {
return <span>{count.toLocaleString()} {label}</span>;
}
function PostGrid({ posts }) {
if (posts.length === 0) return <EmptyState message="No posts yet" />;
return (
<div className="post-grid">
{posts.map(p => (
<PostCard key={p.id} post={p} />
))}
</div>
);
}
function PostCard({ post }) {
return (
<div className="post-card">
<img src={post.image} alt="" />
<p>{post.caption}</p>
<span>{post.likes} likes</span>
</div>
);
}
// === CONTAINER (orchestration layer) ===
function UserProfilePage({ userId }) {
const { user, loading: profileLoading, error, follow } = useUserProfile(userId);
const { posts, loading: postsLoading } = useUserPosts(userId);
const [activeTab, setActiveTab] = useState('posts');
useEffect(() => {
analytics.track('profile_view', { userId });
}, [userId]);
if (profileLoading) return <ProfileSkeleton />;
if (error) return <ErrorMessage error={error} />;
return (
<div className="profile">
<ProfileHeader user={user} onFollow={follow} />
<TabBar
tabs={['posts', 'likes']}
active={activeTab}
onChange={setActiveTab}
/>
{activeTab === 'posts' && (
postsLoading ? <PostGridSkeleton /> : <PostGrid posts={posts} />
)}
</div>
);
}
What We Gained
| Aspect | Before | After |
|---|---|---|
| Files | 1 file, 80 lines | 8 files, 15-40 lines each |
| Test ProfileHeader | Need to mock 2 APIs | Pass user prop |
| Test PostGrid | Need full profile | Pass posts array |
| Reuse PostGrid | Can't | Use in search results, explore page |
| Change avatar size | Read 80 lines | Open ProfileHeader (15 lines) |
| Change API endpoint | Read 80 lines | Open useUserProfile (25 lines) |
7. Case Study: Shopping Cart
Presentational Cart Components
// CartItem — pure UI, knows nothing about state management
function CartItem({ item, onUpdateQuantity, onRemove }) {
return (
<div className="cart-item">
<img src={item.image} alt={item.name} className="w-20 h-20 object-cover" />
<div className="flex-1">
<h4>{item.name}</h4>
<p className="text-gray-500">{item.variant}</p>
<p className="font-bold">${item.price.toFixed(2)}</p>
</div>
<QuantitySelector
value={item.quantity}
min={1}
max={item.maxQuantity}
onChange={(qty) => onUpdateQuantity(item.id, qty)}
/>
<button onClick={() => onRemove(item.id)} className="text-red-500">
Remove
</button>
</div>
);
}
// QuantitySelector — completely reusable
function QuantitySelector({ value, min = 1, max = 99, onChange }) {
return (
<div className="quantity-selector">
<button
onClick={() => onChange(Math.max(min, value - 1))}
disabled={value <= min}
>
−
</button>
<span>{value}</span>
<button
onClick={() => onChange(Math.min(max, value + 1))}
disabled={value >= max}
>
+
</button>
</div>
);
}
// CartSummary — pure calculation display
function CartSummary({ subtotal, tax, shipping, discount, total }) {
return (
<div className="cart-summary">
<SummaryRow label="Subtotal" value={subtotal} />
<SummaryRow label="Tax" value={tax} />
<SummaryRow label="Shipping" value={shipping} highlight={shipping === 0 ? 'FREE' : null} />
{discount > 0 && <SummaryRow label="Discount" value={-discount} className="text-green-600" />}
<hr />
<SummaryRow label="Total" value={total} bold />
</div>
);
}
function SummaryRow({ label, value, bold, highlight, className = '' }) {
return (
<div className={`flex justify-between ${bold ? 'font-bold text-lg' : ''} ${className}`}>
<span>{label}</span>
<span>{highlight || `$${Math.abs(value).toFixed(2)}`}</span>
</div>
);
}
Container Cart Component
// CartPageContainer — orchestrates data and actions
function CartPageContainer() {
const { items, updateQuantity, removeItem, isLoading } = useCart();
const pricing = useMemo(() => calculateCartPricing(items), [items]);
const navigate = useNavigate();
const handleCheckout = () => {
if (items.length === 0) return;
navigate('/checkout');
};
if (isLoading) return <CartSkeleton />;
return (
<CartPage
items={items}
pricing={pricing}
onUpdateQuantity={updateQuantity}
onRemoveItem={removeItem}
onCheckout={handleCheckout}
onContinueShopping={() => navigate('/products')}
/>
);
}
// CartPage — presentational layout
function CartPage({ items, pricing, onUpdateQuantity, onRemoveItem, onCheckout, onContinueShopping }) {
if (items.length === 0) {
return (
<EmptyState
icon="🛒"
title="Your cart is empty"
action={<button onClick={onContinueShopping}>Continue Shopping</button>}
/>
);
}
return (
<div className="cart-page">
<h1>Shopping Cart ({items.length} items)</h1>
<div className="cart-layout">
<div className="cart-items">
{items.map(item => (
<CartItem
key={item.id}
item={item}
onUpdateQuantity={onUpdateQuantity}
onRemove={onRemoveItem}
/>
))}
</div>
<aside className="cart-sidebar">
<CartSummary {...pricing} />
<button onClick={onCheckout} className="btn-primary w-full">
Proceed to Checkout
</button>
<button onClick={onContinueShopping} className="btn-secondary w-full">
Continue Shopping
</button>
</aside>
</div>
</div>
);
}
8. The Modern Hooks Approach
With hooks, you don't always need a separate container component. The custom hook IS the container.
Classic Pattern (Two Components)
// Container component
function UserListContainer() {
const [users, setUsers] = useState([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
fetch('/api/users').then(r => r.json()).then(setUsers).finally(() => setLoading(false));
}, []);
if (loading) return <Spinner />;
return <UserList users={users} />;
}
// Presentational component
function UserList({ users }) {
return (
<ul>
{users.map(u => <li key={u.id}>{u.name}</li>)}
</ul>
);
}
Modern Pattern (Hook + Component)
// Custom hook replaces container
function useUsers() {
const [users, setUsers] = useState([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
fetch('/api/users').then(r => r.json()).then(setUsers).finally(() => setLoading(false));
}, []);
return { users, loading };
}
// Page-level component uses the hook directly
function UserListPage() {
const { users, loading } = useUsers();
if (loading) return <Spinner />;
return <UserList users={users} />;
}
// Presentational component unchanged
function UserList({ users }) {
return (
<ul>
{users.map(u => <li key={u.id}>{u.name}</li>)}
</ul>
);
}
When to Use Which
| Scenario | Approach |
|---|---|
| Simple data → UI | Hook + Component (no container) |
| Multiple data sources composing | Container component |
| Same UI, multiple data sources | Container per data source, shared presentational |
| Complex loading/error orchestration | Container component |
| Design system components | Always presentational |
| Page-level components | Hook-based (acts as both) |
9. When the Pattern Still Matters
Even with hooks, the smart/dumb separation is valuable in these scenarios:
Scenario 1: Design System Components
Design system components (Button, Card, Modal, Table, Select) should ALWAYS be presentational. They should never know where data comes from.
// ✅ Design system Select — purely presentational
function Select({ options, value, onChange, placeholder, disabled }) {
return (
<select value={value} onChange={e => onChange(e.target.value)} disabled={disabled}>
{placeholder && <option value="">{placeholder}</option>}
{options.map(opt => (
<option key={opt.value} value={opt.value}>{opt.label}</option>
))}
</select>
);
}
// ✅ Feature-specific "smart" wrapper
function CountrySelect({ value, onChange }) {
const { countries, loading } = useCountries();
if (loading) return <Select options={[]} placeholder="Loading..." disabled />;
return (
<Select
options={countries.map(c => ({ value: c.code, label: c.name }))}
value={value}
onChange={onChange}
placeholder="Select country"
/>
);
}
Scenario 2: Same UI, Multiple Data Sources
// One presentational component
function ProductGrid({ products, onProductClick }) {
return (
<div className="grid grid-cols-3 gap-4">
{products.map(p => (
<ProductCard key={p.id} product={p} onClick={() => onProductClick(p)} />
))}
</div>
);
}
// Three different containers using the SAME presentational component
function FeaturedProducts() {
const { products } = useFeaturedProducts();
return <ProductGrid products={products} onProductClick={navigateToProduct} />;
}
function SearchResults({ query }) {
const { products } = useSearch(query);
return <ProductGrid products={products} onProductClick={navigateToProduct} />;
}
function CategoryProducts({ categoryId }) {
const { products } = useCategoryProducts(categoryId);
return <ProductGrid products={products} onProductClick={navigateToProduct} />;
}
Scenario 3: Server Components (Next.js App Router)
The smart/dumb pattern maps perfectly to Server Components (smart) and Client Components (dumb):
// Server Component — "smart" (fetches data on server)
// app/products/page.jsx
async function ProductsPage() {
const products = await db.products.findMany();
return <ProductList products={products} />;
}
// Client Component — "dumb" (interactive UI)
// components/ProductList.jsx
'use client';
function ProductList({ products }) {
const [sort, setSort] = useState('name');
const sorted = useMemo(() => sortProducts(products, sort), [products, sort]);
return (
<div>
<SortSelect value={sort} onChange={setSort} />
{sorted.map(p => <ProductCard key={p.id} product={p} />)}
</div>
);
}
10. Testing Benefits
The biggest benefit of smart/dumb separation is testability.
Testing Presentational Components
// ✅ Super easy — just pass props
describe('UserCard', () => {
const user = {
name: 'Alice',
avatar: '/alice.jpg',
role: 'Admin',
};
test('renders user name', () => {
render(<UserCard {...user} onEdit={() => {}} onDelete={() => {}} />);
expect(screen.getByText('Alice')).toBeInTheDocument();
});
test('calls onEdit when edit clicked', () => {
const onEdit = vi.fn();
render(<UserCard {...user} onEdit={onEdit} onDelete={() => {}} />);
fireEvent.click(screen.getByText('Edit'));
expect(onEdit).toHaveBeenCalled();
});
test('renders role badge', () => {
render(<UserCard {...user} onEdit={() => {}} onDelete={() => {}} />);
expect(screen.getByText('Admin')).toBeInTheDocument();
});
});
Testing Container Components
// More complex — need to mock data sources
describe('UserListContainer', () => {
test('shows loading state', () => {
// Must mock the hook or API
vi.spyOn(global, 'fetch').mockImplementation(
() => new Promise(() => {}) // Never resolves → loading forever
);
render(<UserListContainer />);
expect(screen.getByText('Loading...')).toBeInTheDocument();
});
test('shows users after loading', async () => {
vi.spyOn(global, 'fetch').mockResolvedValue({
json: () => Promise.resolve([{ id: 1, name: 'Alice' }]),
});
render(<UserListContainer />);
await waitFor(() => {
expect(screen.getByText('Alice')).toBeInTheDocument();
});
});
});
Testing Custom Hooks
// Clean hook testing with renderHook
describe('useUsers', () => {
test('fetches and returns users', async () => {
vi.spyOn(global, 'fetch').mockResolvedValue({
json: () => Promise.resolve([{ id: 1, name: 'Alice' }]),
});
const { result } = renderHook(() => useUsers());
expect(result.current.loading).toBe(true);
await waitFor(() => {
expect(result.current.loading).toBe(false);
expect(result.current.users).toEqual([{ id: 1, name: 'Alice' }]);
});
});
});
Test Distribution
| Component Type | Test Focus | Ease |
|---|---|---|
| Presentational | Renders correctly, calls callbacks | ⭐⭐⭐⭐⭐ Easy |
| Container | Loading/error states, orchestration | ⭐⭐⭐ Medium |
| Custom Hook | Data logic, state transitions | ⭐⭐⭐⭐ Easy with renderHook |
| Utility Function | Pure logic | ⭐⭐⭐⭐⭐ Easiest |
11. Storybook and Design Systems
Presentational components are perfect for Storybook — they can be viewed in isolation with any data.
// UserCard.stories.jsx
export default {
title: 'Components/UserCard',
component: UserCard,
};
// Each story is a different visual state
export const Default = {
args: {
name: 'Alice Johnson',
avatar: 'https://i.pravatar.cc/150?u=alice',
role: 'Developer',
},
};
export const Admin = {
args: {
name: 'Bob Smith',
avatar: 'https://i.pravatar.cc/150?u=bob',
role: 'Admin',
},
};
export const LongName = {
args: {
name: 'Bartholomew Alexander Montgomery III',
avatar: 'https://i.pravatar.cc/150?u=bart',
role: 'Engineering Manager',
},
};
export const NoAvatar = {
args: {
name: 'Charlie',
avatar: '',
role: 'Intern',
},
};
This is only possible because UserCard is presentational — it doesn't need an API, a database, or authentication to render.
12. Common Mistakes
Mistake 1: Making Everything a Container
// ❌ Over-separation: container for a single <h1>
function PageTitleContainer() {
const { title } = usePageTitle();
return <PageTitle title={title} />;
}
function PageTitle({ title }) {
return <h1>{title}</h1>;
}
// ✅ Just use the hook directly — no container needed for something this simple
function PageTitle() {
const { title } = usePageTitle();
return <h1>{title}</h1>;
}
Mistake 2: Presentational Components That Fetch Data "Just Once"
// ❌ "It only fetches once, so it's fine"
function Avatar({ userId }) {
const [url, setUrl] = useState('');
useEffect(() => {
fetch(`/api/users/${userId}/avatar`).then(r => r.json()).then(d => setUrl(d.url));
}, [userId]);
return <img src={url} alt="" />;
}
// ✅ Presentational — receives URL as prop
function Avatar({ src, alt, size = 'md' }) {
const sizes = { sm: 'w-8 h-8', md: 'w-12 h-12', lg: 'w-20 h-20' };
return <img src={src} alt={alt} className={`rounded-full ${sizes[size]}`} />;
}
Mistake 3: Leaking Container Logic into Presentational Props
// ❌ Presentational component receives a "refetch" function
function UserList({ users, onRefetch }) {
return (
<div>
<button onClick={onRefetch}>Refresh</button> {/* ← This is a container concern */}
{users.map(u => <UserCard key={u.id} user={u} />)}
</div>
);
}
// ✅ Better: the presentational component just signals intent
function UserList({ users, onRefresh }) {
return (
<div>
<button onClick={onRefresh}>Refresh</button> {/* ← Just a callback, could do anything */}
{users.map(u => <UserCard key={u.id} user={u} />)}
</div>
);
}
// The container decides what "refresh" means (refetch API, clear cache, etc.)
Mistake 4: Mixing Smart and Dumb in the Same Component
// ❌ Half presentational, half container
function ProductCard({ productId }) {
// Container behavior
const { product } = useProduct(productId);
const { addToCart } = useCart();
// Presentational behavior
return (
<div className="card">
<img src={product.image} alt={product.name} />
<h3>{product.name}</h3>
<button onClick={() => addToCart(productId)}>Add to Cart</button>
</div>
);
}
// ✅ Clean separation
function ProductCard({ product, onAddToCart }) {
return (
<div className="card">
<img src={product.image} alt={product.name} />
<h3>{product.name}</h3>
<button onClick={onAddToCart}>Add to Cart</button>
</div>
);
}
13. Decision Framework
┌───────────────────────────┐
│ Am I building a reusable │
│ design system component? │
└─────────────┬─────────────┘
YES │ NO
┌─────────────┘ └───────────────────┐
▼ ▼
┌──────────────┐ ┌───────────────────────┐
│ PRESENTATIONAL│ │ Does this component │
│ Always. │ │ fetch data or have │
│ No exceptions.│ │ side effects? │
└──────────────┘ └───────────┬───────────┘
YES │ NO
┌────────────┘ └──────┐
▼ ▼
┌──────────────────┐ ┌──────────────┐
│ Will the same UI │ │ PRESENTATIONAL│
│ be used with │ │ (or simple │
│ different data? │ │ hook-based) │
└────────┬─────────┘ └──────────────┘
YES │ NO
┌─────────┘ └──────┐
▼ ▼
┌──────────────┐ ┌──────────────┐
│ SPLIT into │ │ Hook + single│
│ Container + │ │ component is │
│ Presentational│ │ fine │
└──────────────┘ └──────────────┘
14. Real-World File Organization
Feature-Based Organization
src/
features/
users/
components/
UserCard.jsx ← Presentational
UserList.jsx ← Presentational
UserForm.jsx ← Presentational
UserListContainer.jsx ← Container (optional with hooks)
hooks/
useUsers.js ← Data fetching
useUserForm.js ← Form logic
utils/
userValidation.js ← Pure functions
index.js ← Public exports
cart/
components/
CartItem.jsx ← Presentational
CartSummary.jsx ← Presentational
hooks/
useCart.js ← Cart state + operations
utils/
pricing.js ← Price calculations
components/ ← Shared presentational components
Button.jsx
Card.jsx
Modal.jsx
DataTable.jsx
EmptyState.jsx
ErrorMessage.jsx
LoadingSpinner.jsx
hooks/ ← Shared hooks
useDebounce.js
useDocumentTitle.js
useLocalStorage.js
Export Pattern
// features/users/index.js
// Export presentational components for reuse
export { UserCard } from './components/UserCard';
export { UserList } from './components/UserList';
// Export hooks for custom composition
export { useUsers } from './hooks/useUsers';
// Export the "ready-to-use" container for pages
export { UserListContainer } from './components/UserListContainer';
15. Key Takeaways
-
Presentational components receive data via props and render UI. They have no side effects, no data fetching, and are highly reusable and testable.
-
Container components (or custom hooks) handle data fetching, state management, and side effects. They render minimal DOM, mostly delegating to presentational children.
-
The principle matters more than the implementation. Whether you use a container component or a custom hook, the separation of "where data comes from" and "how data looks" is what matters.
-
Design system components are ALWAYS presentational. No exceptions.
-
Hooks modernized the pattern — the custom hook replaces the container component in many cases, but the underlying separation of concerns remains identical.
-
Test accordingly: Presentational components with simple prop-based tests. Container logic with hook tests or integration tests.
-
Don't over-apply: Not every component needs a container/presentational split. Simple components that use one hook and render UI are fine as-is.
Explain-It Challenge
-
Explain to a product designer: Why should the Button component they designed NOT know about the shopping cart API? Use a real-world analogy.
-
Spot the smell: Given a component with
useStateforisModalOpen,selectedTab, andusers(from API) — which state belongs in a presentational component and which in a container/hook? -
Design the split: You have a
NotificationPanelthat fetches notifications via WebSocket, shows a badge count, renders a dropdown list, and plays a sound on new notifications. Draw the component/hook boundaries.
Navigation: ← Single Responsibility Principle · Next → Lifting State Up