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

  1. The Core Idea
  2. History and Naming
  3. Presentational (Dumb) Components
  4. Container (Smart) Components
  5. Side-by-Side Comparison
  6. Case Study: User Profile
  7. Case Study: Shopping Cart
  8. The Modern Hooks Approach
  9. When the Pattern Still Matters
  10. Testing Benefits
  11. Storybook and Design Systems
  12. Common Mistakes
  13. Decision Framework
  14. Real-World File Organization
  15. Key Takeaways

1. The Core Idea

Every piece of UI involves two concerns:

  1. Where does the data come from? (APIs, state, context, URL params)
  2. 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.

EraHow separation is achieved
2015–2018Container component (class) + Presentational component (function)
2019–2022Custom hook (data) + Component (UI)
2023+Server Component (data) + Client Component (UI)

The principle stays the same. The implementation evolves.

Modern Terminology

Classic TermModern EquivalentDescription
Container / Smart"Data component" or custom hookHandles 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:

  1. Receives all data via props — never fetches its own data
  2. Has no side effects — no useEffect, no API calls, no localStorage
  3. May have internal UI state — things like isOpen, activeTab, hoveredIndex
  4. Renders DOM elements — the actual HTML/CSS
  5. Calls callback propsonClick, onSubmit, onChange — but doesn't know what happens
  6. 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 / isExpandedusers from API
activeTabcurrentUser from auth
hoveredIndexcartItems from server
inputValue (before submit)notifications from WebSocket
animationPhasesearchResults from API
tooltipPositionformErrors 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:

  1. Fetches data — from APIs, context, URL params, localStorage
  2. Manages state — loading, error, data transformations
  3. Has side effects — useEffect, subscriptions, analytics
  4. Renders minimal or no DOM — mostly renders child components
  5. Passes data down as props — bridges data sources to UI components
  6. 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

AspectPresentational (Dumb)Container (Smart)
PurposeHow things lookHow things work
Data sourceProps onlyAPI, context, hooks, URL
StateUI state only (isOpen, activeTab)Data state (users, loading, error)
Side effectsNoneuseEffect, subscriptions
DOM outputHTML elements with stylingMinimal — renders child components
Reusable?Yes — works with any dataNo — tied to specific data source
Testable?Very — pass props, check outputHarder — must mock data sources
ExamplesButton, Card, UserList, ModalUserListPage, CartContainer, AuthGate
Who creates?UI/design teamFeature developers
Changes whenDesign changesData/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

AspectBeforeAfter
Files1 file, 80 lines8 files, 15-40 lines each
Test ProfileHeaderNeed to mock 2 APIsPass user prop
Test PostGridNeed full profilePass posts array
Reuse PostGridCan'tUse in search results, explore page
Change avatar sizeRead 80 linesOpen ProfileHeader (15 lines)
Change API endpointRead 80 linesOpen 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

ScenarioApproach
Simple data → UIHook + Component (no container)
Multiple data sources composingContainer component
Same UI, multiple data sourcesContainer per data source, shared presentational
Complex loading/error orchestrationContainer component
Design system componentsAlways presentational
Page-level componentsHook-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 TypeTest FocusEase
PresentationalRenders correctly, calls callbacks⭐⭐⭐⭐⭐ Easy
ContainerLoading/error states, orchestration⭐⭐⭐ Medium
Custom HookData logic, state transitions⭐⭐⭐⭐ Easy with renderHook
Utility FunctionPure 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

  1. Presentational components receive data via props and render UI. They have no side effects, no data fetching, and are highly reusable and testable.

  2. Container components (or custom hooks) handle data fetching, state management, and side effects. They render minimal DOM, mostly delegating to presentational children.

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

  4. Design system components are ALWAYS presentational. No exceptions.

  5. Hooks modernized the pattern — the custom hook replaces the container component in many cases, but the underlying separation of concerns remains identical.

  6. Test accordingly: Presentational components with simple prop-based tests. Container logic with hook tests or integration tests.

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

  1. Explain to a product designer: Why should the Button component they designed NOT know about the shopping cart API? Use a real-world analogy.

  2. Spot the smell: Given a component with useState for isModalOpen, selectedTab, and users (from API) — which state belongs in a presentational component and which in a container/hook?

  3. Design the split: You have a NotificationPanel that 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