Container-Presenter Pattern in React
Split smart containers from dumb presenters — fix code smells, isolate API logic, improve testability, and refactor mammoth components with props conventions, use cases, pitfalls, and modern custom-hook evolution.
Introduction
The Container-Presenter pattern (also called Smart/Dumb or Container/View) separates what data means from how it looks. One component owns fetching, state, and business rules; another receives props and renders UI. The split keeps features testable, reusable, and easier to maintain as they grow.
This guide covers:
- Code smells the pattern fixes
- Container role — smart, logic-heavy, no presentation markup
- Presenter role — dumb, prop-driven, styling-focused
- Refactoring strategy — step-by-step extraction
- Best use cases and pitfalls
- Prerequisites and the modern custom hook evolution
Quick index
1. Problem: code smells
Before adopting the pattern, recognize when a single component is doing too much.
| Code smell | Symptom | Why it hurts |
|---|---|---|
| Single Responsibility violation | One file fetches, validates, formats, and renders | Changes to API shape break JSX tests |
| Low reusability | Same table markup copy-pasted with different fetch logic | UI tweaks require edits in multiple places |
| Poor testability | Must mock fetch to test a button label | Slow, brittle unit tests |
| Difficult maintenance | 300+ line component with nested ternaries | Onboarding and code review slow down |
Before — everything in one component
'use client'
import { useEffect, useState } from 'react'
type Order = { id: string; total: number; status: string }
export function OrdersPage() {
const [orders, setOrders] = useState<Order[]>([])
const [loading, setLoading] = useState(true)
const [filter, setFilter] = useState<'all' | 'pending'>('all')
useEffect(() => {
fetch('/api/orders')
.then((r) => r.json())
.then(setOrders)
.finally(() => setLoading(false))
}, [])
const visible = filter === 'all' ? orders : orders.filter((o) => o.status === 'pending')
if (loading) return <p className="text-sm text-neutral-500">Loading orders…</p>
return (
<section>
<h1 className="text-2xl font-bold">Orders</h1>
<div className="mb-4 flex gap-2">
<button onClick={() => setFilter('all')}>All</button>
<button onClick={() => setFilter('pending')}>Pending</button>
</div>
<table className="w-full border">
<thead>
<tr>
<th>ID</th>
<th>Total</th>
<th>Status</th>
</tr>
</thead>
<tbody>
{visible.map((o) => (
<tr key={o.id}>
<td>{o.id}</td>
<td>${o.total.toFixed(2)}</td>
<td>{o.status}</td>
</tr>
))}
</tbody>
</table>
</section>
)
}2. Prerequisites
You should be comfortable with these before splitting components:
| Concept | Used for |
|---|---|
| JSX | Presenter markup and composition |
| Props & state | Data flow from container → presenter |
useState | Loading flags, filters, form values in the container |
useEffect | Side effects — fetch on mount, subscribe, sync URL |
'use client'
import { useEffect, useState } from 'react'
// Minimal mental model: state lives in one place, flows down as props
function CounterContainer() {
const [count, setCount] = useState(0)
useEffect(() => {
document.title = `Count: ${count}`
}, [count])
return <CounterPresenter count={count} onIncrement={() => setCount((c) => c + 1)} />
}
function CounterPresenter({ count, onIncrement }: { count: number; onIncrement: () => void }) {
return <button onClick={onIncrement}>{count}</button>
}3. Container component (smart)
The container is the brain. It knows where data comes from and what to do with user actions.
| Responsibility | In scope | Out of scope |
|---|---|---|
| Data fetching (APIs) | ✅ | |
| State management | ✅ | |
| Business logic (filter, sort, validate) | ✅ | |
| Passes data via props | ✅ | |
| JSX presentation / layout | ❌ | |
| CSS styling details | ❌ |
'use client'
import { useCallback, useEffect, useMemo, useState } from 'react'
import { OrdersTablePresenter } from './OrdersTablePresenter'
export type Order = { id: string; total: number; status: 'pending' | 'shipped' | 'cancelled' }
type Filter = 'all' | 'pending'
export function OrdersContainer() {
const [orders, setOrders] = useState<Order[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [filter, setFilter] = useState<Filter>('all')
useEffect(() => {
let cancelled = false
fetch('/api/orders')
.then((r) => {
if (!r.ok) throw new Error('Failed to load orders')
return r.json()
})
.then((data: Order[]) => {
if (!cancelled) setOrders(data)
})
.catch((e: Error) => {
if (!cancelled) setError(e.message)
})
.finally(() => {
if (!cancelled) setLoading(false)
})
return () => {
cancelled = true
}
}, [])
const visibleOrders = useMemo(
() => (filter === 'all' ? orders : orders.filter((o) => o.status === 'pending')),
[orders, filter],
)
const handleFilterChange = useCallback((next: Filter) => {
setFilter(next)
}, [])
return (
<OrdersTablePresenter
orders={visibleOrders}
loading={loading}
error={error}
filter={filter}
onFilterChange={handleFilterChange}
/>
)
}4. Presenter component (dumb)
The presenter is the face. It renders UI from props and may hold local UI-only state (open/closed, hover, focus) — never server data or API calls.
| Responsibility | In scope | Out of scope |
|---|---|---|
| UI structure & styling | ✅ | |
| Receives data/functions as props | ✅ | |
| Local UI state (dropdown open, tooltip) | ✅ | |
| Sub-presenters (rows, cells, badges) | ✅ | |
| Data fetching | ❌ | |
| Global business rules | ❌ |
'use client'
import { useState } from 'react'
import type { Order } from './OrdersContainer'
type Filter = 'all' | 'pending'
type Props = {
orders: Order[]
loading: boolean
error: string | null
filter: Filter
onFilterChange: (filter: Filter) => void
}
export function OrdersTablePresenter({ orders, loading, error, filter, onFilterChange }: Props) {
const [expandedId, setExpandedId] = useState<string | null>(null) // local UI state only
if (loading) return <p className="text-sm text-neutral-500">Loading orders…</p>
if (error) return <p className="text-sm text-red-600">{error}</p>
return (
<section>
<h1 className="text-2xl font-bold">Orders</h1>
<FilterBar filter={filter} onFilterChange={onFilterChange} />
<table className="w-full border">
<thead>
<tr>
<th>ID</th>
<th>Total</th>
<th>Status</th>
</tr>
</thead>
<tbody>
{orders.map((order) => (
<OrderRow
key={order.id}
order={order}
expanded={expandedId === order.id}
onToggle={() => setExpandedId((id) => (id === order.id ? null : order.id))}
/>
))}
</tbody>
</table>
</section>
)
}
function FilterBar({ filter, onFilterChange }: { filter: Filter; onFilterChange: (f: Filter) => void }) {
return (
<div className="mb-4 flex gap-2">
<button aria-pressed={filter === 'all'} onClick={() => onFilterChange('all')}>
All
</button>
<button aria-pressed={filter === 'pending'} onClick={() => onFilterChange('pending')}>
Pending
</button>
</div>
)
}
function OrderRow({ order, expanded, onToggle }: { order: Order; expanded: boolean; onToggle: () => void }) {
return (
<>
<tr onClick={onToggle} className="cursor-pointer">
<td>{order.id}</td>
<td>${order.total.toFixed(2)}</td>
<td>{order.status}</td>
</tr>
{expanded && (
<tr>
<td colSpan={3} className="bg-neutral-50 p-2 text-sm">
Order details for {order.id}…
</td>
</tr>
)}
</>
)
}5. Full example: product catalog
A realistic split for a product catalog — container handles search + fetch; presenter handles grid layout.
// useProductCatalog.ts — logic extracted (container as a hook)
'use client'
import { useEffect, useMemo, useState } from 'react'
export type Product = { id: string; name: string; price: number; category: string }
export function useProductCatalog() {
const [products, setProducts] = useState<Product[]>([])
const [query, setQuery] = useState('')
const [loading, setLoading] = useState(true)
useEffect(() => {
fetch('/api/products')
.then((r) => r.json())
.then(setProducts)
.finally(() => setLoading(false))
}, [])
const filtered = useMemo(
() => products.filter((p) => p.name.toLowerCase().includes(query.trim().toLowerCase())),
[products, query],
)
return { products: filtered, query, setQuery, loading }
}// ProductCatalogContainer.tsx
'use client'
import { useProductCatalog } from './useProductCatalog'
import { ProductGridPresenter } from './ProductGridPresenter'
export function ProductCatalogContainer() {
const { products, query, setQuery, loading } = useProductCatalog()
return <ProductGridPresenter products={products} query={query} onQueryChange={setQuery} loading={loading} />
}// ProductGridPresenter.tsx
import type { Product } from './useProductCatalog'
type Props = {
products: Product[]
query: string
onQueryChange: (value: string) => void
loading: boolean
}
export function ProductGridPresenter({ products, query, onQueryChange, loading }: Props) {
return (
<div>
<input
value={query}
onChange={(e) => onQueryChange(e.target.value)}
placeholder="Search products…"
className="mb-6 w-full max-w-md border px-3 py-2"
/>
{loading ? (
<p>Loading catalog…</p>
) : (
<ul className="grid grid-cols-2 gap-4 md:grid-cols-3">
{products.map((p) => (
<li key={p.id} className="rounded border p-4">
<h2 className="font-semibold">{p.name}</h2>
<p className="text-sm text-neutral-600">{p.category}</p>
<p className="mt-2 font-bold">${p.price}</p>
</li>
))}
</ul>
)}
</div>
)
}6. Refactoring strategy
Move an existing mammoth component to Container-Presenter in four steps.
Step 1 — Isolate API logic to the container
Move every fetch, axios, or server-action call out of JSX files. The presenter should not import HTTP clients.
// ❌ inside presenter
useEffect(() => { fetch('/api/users')… }, [])
// ✅ inside container (or useUsers hook)
const users = useUsers()
return <UserListPresenter users={users.data} loading={users.loading} />Step 2 — Create reusable common components
Extract repeated UI (buttons, badges, empty states) into shared presenters under components/ui/ or components/common/.
export function EmptyState({ message }: { message: string }) {
return <div className="rounded border border-dashed p-8 text-center text-neutral-500">{message}</div>
}Step 3 — Decompose mammoth JSX
Split large return blocks into sub-presenters. Rule of thumb: if a JSX section has its own heading or could appear on another page, extract it.
| Before | After |
|---|---|
200-line return (...) | Header + Filters + Table + Pagination |
| Inline map with 40 lines per row | OrderRow sub-presenter |
Step 4 — Implement consistent naming
| Prop (presenter receives) | Handler (container defines) |
|---|---|
onSubmit | handleSubmit |
onFilterChange | handleFilterChange |
onDelete | handleDelete |
Data props stay nouns (orders, user, isLoading); callbacks start with on.
7. Best use cases
| Use case | Why Container-Presenter fits |
|---|---|
| Data-heavy dashboards | Multiple API sources, derived metrics, and chart presenters share one container |
| Complex forms & wizards | Container owns step state and validation; each step is a presenter |
| Product catalogs | Same grid/list/table presenters with different containers (sale, search, category) |
| Analytics reports | Container aggregates and normalizes data; presenters focus on charts and tables |
Dashboard example — one container, many presenters
'use client'
import { useDashboardMetrics } from './useDashboardMetrics'
import { KpiCardsPresenter } from './KpiCardsPresenter'
import { RevenueChartPresenter } from './RevenueChartPresenter'
import { TopProductsPresenter } from './TopProductsPresenter'
export function DashboardContainer() {
const { kpis, revenueSeries, topProducts, loading, error } = useDashboardMetrics()
if (error) return <p>Dashboard unavailable: {error}</p>
return (
<div className="space-y-8">
<KpiCardsPresenter kpis={kpis} loading={loading} />
<RevenueChartPresenter data={revenueSeries} loading={loading} />
<TopProductsPresenter products={topProducts} loading={loading} />
</div>
)
}8. Pitfalls & anti-patterns
| Pitfall | Problem | Fix |
|---|---|---|
| Overengineering simple components | Splitting a static <Badge> into container + presenter | Use the pattern when logic and UI complexity justify it |
| Excessive prop drilling | Container → presenter → row → cell → icon, all props | Lift shared data into Context or pass a single row object |
| Leaky presenter | Presenter imports fetch or reads global config | Move all side effects to container/hook |
| God container | Container still renders 400 lines of JSX | Container should return <Presenter {...props} /> — not layout |
| Ignoring Context for deep trees | Passing theme, user, locale through every presenter | Context for cross-cutting read-only data; Container-Presenter for feature state |
9. Modern evolution: custom hooks
In current React codebases, the container is often a custom hook, not a separate component file. The separation of concerns stays identical.
// useOrders.ts — container logic as a hook
'use client'
import { useCallback, useEffect, useMemo, useState } from 'react'
export function useOrders() {
const [orders, setOrders] = useState<Order[]>([])
const [loading, setLoading] = useState(true)
const [filter, setFilter] = useState<'all' | 'pending'>('all')
useEffect(() => {
fetch('/api/orders')
.then((r) => r.json())
.then(setOrders)
.finally(() => setLoading(false))
}, [])
const visibleOrders = useMemo(
() => (filter === 'all' ? orders : orders.filter((o) => o.status === 'pending')),
[orders, filter],
)
const onFilterChange = useCallback((f: 'all' | 'pending') => setFilter(f), [])
return { orders: visibleOrders, loading, filter, onFilterChange }
}
// OrdersPage.tsx — thin wrapper or skip entirely
export function OrdersPage() {
const props = useOrders()
return <OrdersTablePresenter {...props} />
}| Era | Container shape | Presenter |
|---|---|---|
| Class components | connect() HOC or container class | Stateless function |
| Hooks (2019+) | Function + useEffect / useState | Function receiving props |
| Modern (2024+) | Custom hook + optional thin wrapper | Same presenter, unchanged |
Summary
| Layer | Role | Owns |
|---|---|---|
| Container / hook | Smart | APIs, state, business logic, handlers |
| Presenter | Dumb | Layout, styles, local UI state, sub-presenters |
Container-Presenter fixes SRP violations, improves testability, and unlocks UI reuse across features. Refactor by isolating fetch logic, extracting sub-presenters, and naming props consistently — then evolve containers into custom hooks without changing presenters.
Next reads: React Design Patterns — Complete Guide for the full 15-pattern roadmap, and TanStack Query for server-state in modern container hooks.
Subscribe to my newsletter
Stay up to date and get notified when I share new contents.
No spam ever, unsubscribe anytime