Kazi Rahamatullah
Kazi Rahamatullah
AboutProjectsBlogContact
UsesBooks
ResumeView CV
Kazi Rahamatullah

© Copyright 2026 Kazi Rahamatullah

AboutProjectsBlogBooksUses
Twitter/XGitHubProduct HuntCodeSandbox
Back to Blog
ReactDesign PatternsContainer-PresenterArchitectureRefactoring

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.

Jul 3, 202614 min read

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:

  1. Code smells the pattern fixes
  2. Container role — smart, logic-heavy, no presentation markup
  3. Presenter role — dumb, prop-driven, styling-focused
  4. Refactoring strategy — step-by-step extraction
  5. Best use cases and pitfalls
  6. Prerequisites and the modern custom hook evolution

Note

Container-Presenter is a separation-of-concerns pattern, not a file-naming rule. You can colocate both in one folder or merge container logic into a custom hook — the boundary between logic and UI is what matters.

Quick index

#Section
1Problem: code smells
2Prerequisites
3Container component (smart)
4Presenter component (dumb)
5Full example: product catalog
6Refactoring strategy
7Best use cases
8Pitfalls & anti-patterns
9Modern evolution: custom hooks

1. Problem: code smells

Before adopting the pattern, recognize when a single component is doing too much.

Code smellSymptomWhy it hurts
Single Responsibility violationOne file fetches, validates, formats, and rendersChanges to API shape break JSX tests
Low reusabilitySame table markup copy-pasted with different fetch logicUI tweaks require edits in multiple places
Poor testabilityMust mock fetch to test a button labelSlow, brittle unit tests
Difficult maintenance300+ line component with nested ternariesOnboarding and code review slow down

Before — everything in one component

tsx
'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>
  )
}

Warning

This component violates SRP: it owns API access, filtering business logic, loading state, and table layout. A designer changing column order forces you to touch fetch code — and vice versa.

Back to index


2. Prerequisites

You should be comfortable with these before splitting components:

ConceptUsed for
JSXPresenter markup and composition
Props & stateData flow from container → presenter
useStateLoading flags, filters, form values in the container
useEffectSide effects — fetch on mount, subscribe, sync URL
tsx
'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>
}

Note

If props and state are still fuzzy, read React Design Patterns — Component communication first, then return here.

Back to index


3. Container component (smart)

The container is the brain. It knows where data comes from and what to do with user actions.

ResponsibilityIn scopeOut of scope
Data fetching (APIs)✅
State management✅
Business logic (filter, sort, validate)✅
Passes data via props✅
JSX presentation / layout❌
CSS styling details❌
tsx
'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}
    />
  )
}

Tip

Naming convention: pass callbacks as onAction (onFilterChange, onSubmit) from the container; define handlers as handleAction (handleFilterChange) inside the container. Presenters call onFilterChange — they never name internal handler logic.

Back to index


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.

ResponsibilityIn scopeOut 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❌
tsx
'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>
      )}
    </>
  )
}

Note

Sub-presenters (FilterBar, OrderRow) keep the top-level presenter readable. Each sub-presenter is still "dumb" — props in, JSX out.

Performance

Presenters are ideal React.memo candidates: when parent containers re-render but props are stable, memoized presenters skip redundant DOM work. Profile first — do not memo by default.

Back to index


5. Full example: product catalog

A realistic split for a product catalog — container handles search + fetch; presenter handles grid layout.

tsx
// 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 }
}
tsx
// 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} />
}
tsx
// 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>
  )
}

Tip

Swap ProductGridPresenter for ProductListPresenter or ProductTablePresenter without touching fetch logic — that reuse is the main payoff of the pattern.

Back to index


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.

tsx
// ❌ 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/.

tsx
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.

BeforeAfter
200-line return (...)Header + Filters + Table + Pagination
Inline map with 40 lines per rowOrderRow sub-presenter

Step 4 — Implement consistent naming

Prop (presenter receives)Handler (container defines)
onSubmithandleSubmit
onFilterChangehandleFilterChange
onDeletehandleDelete

Data props stay nouns (orders, user, isLoading); callbacks start with on.

Note

Refactor incrementally. Extract the presenter first with the same props the monolith already had — then move logic into the container. Small PRs beat a big-bang rewrite.

Back to index


7. Best use cases

Use caseWhy Container-Presenter fits
Data-heavy dashboardsMultiple API sources, derived metrics, and chart presenters share one container
Complex forms & wizardsContainer owns step state and validation; each step is a presenter
Product catalogsSame grid/list/table presenters with different containers (sale, search, category)
Analytics reportsContainer aggregates and normalizes data; presenters focus on charts and tables

Dashboard example — one container, many presenters

tsx
'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>
  )
}

Tip

For dashboards, the container often becomes a custom hook (useDashboardMetrics) that composes several queries — presenters stay pure while TanStack Query or SWR lives in the hook layer.

Back to index


8. Pitfalls & anti-patterns

PitfallProblemFix
Overengineering simple componentsSplitting a static <Badge> into container + presenterUse the pattern when logic and UI complexity justify it
Excessive prop drillingContainer → presenter → row → cell → icon, all propsLift shared data into Context or pass a single row object
Leaky presenterPresenter imports fetch or reads global configMove all side effects to container/hook
God containerContainer still renders 400 lines of JSXContainer should return <Presenter {...props} /> — not layout
Ignoring Context for deep treesPassing theme, user, locale through every presenterContext for cross-cutting read-only data; Container-Presenter for feature state

Warning

Do not split a <Counter /> with one useState into two files — that adds ceremony without fixing a real smell. The pattern pays off when fetch logic, business rules, or layout reuse enter the picture.

Warning

When props pass through more than two presenter levels, combine Container-Presenter with the Provider pattern — not more renamed props.

Back to index


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.

tsx
// 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} />
}
EraContainer shapePresenter
Class componentsconnect() HOC or container classStateless function
Hooks (2019+)Function + useEffect / useStateFunction receiving props
Modern (2024+)Custom hook + optional thin wrapperSame presenter, unchanged

Interview Answer

"Is Container-Presenter still relevant with hooks?"

Yes — the idea is more relevant than the file split. Hooks let you extract container logic into useFeature() while presenters remain easy to test and reuse. Interviewers care that you can separate data/orchestration from UI, not that you have a file literally named UserListContainer.tsx.

Tip

Production checklist

  • Container (or hook): fetch, error handling, business rules, on* callbacks
  • Presenter: JSX, styling, local UI state only
  • Sub-presenters for rows, cards, and form steps
  • onAction / handleAction naming consistency
  • Test presenters with plain props — no network mocks
  • Combine with Context when prop drilling exceeds two levels
  • Do not split trivial components

Back to index


Summary

LayerRoleOwns
Container / hookSmartAPIs, state, business logic, handlers
PresenterDumbLayout, 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.

Share this article

XLinkedInFacebook
Kazi Rahamatullah

Written by

Kazi Rahamatullah

FullStack Developer

X / TwitterGitHubLinkedIn

Subscribe to my newsletter

Stay up to date and get notified when I share new contents.

No spam ever, unsubscribe anytime