Smart ComponentsDumb ComponentsContainerPresentationalReact Patterns

Smart vs Dumb Components

The oldest and most useful component pattern in React. Smart (container) components handle logic, state, and data fetching. Dumb (presentational) components just render UI from props. Separating the two makes code reusable, testable, and easy to reason about.

20 min read14 sections
01

Overview

Smart components (also called container components) manage state, fetch data, handle business logic, and pass results to their children. Dumb components (also called presentational components) receive data via props and render UI — nothing more. They don't know where the data comes from or what happens when the user clicks a button.

This pattern was popularized by Dan Abramov in 2015 and became the dominant way to structure React applications. While hooks have blurred the line somewhat, the principle — separate what the component does from how it looks — remains one of the most important ideas in frontend architecture.

In interviews, this topic tests whether you understand component responsibility, reusability, and testability. A strong answer explains the pattern, acknowledges how hooks changed it, and knows when strict separation helps vs when it's overkill.

Why this matters

A component that fetches data AND renders a complex UI is hard to reuse, hard to test, and hard to change. Splitting it into a smart component (data) and a dumb component (UI) makes each piece simple, focused, and independently useful.

02

The Problem: Mixing Logic & UI

When a single component handles data fetching, state management, event handling, and rendering, it becomes a monolith. Every change is risky, every test is complex, and nothing is reusable.

❌ Mixed Component — Everything in One Placejsx
function UserList() {
  const [users, setUsers] = useState([]);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState(null);
  const [searchQuery, setSearchQuery] = useState("");
  const [sortBy, setSortBy] = useState("name");

  useEffect(() => {
    setLoading(true);
    fetch("/api/users")
      .then(res => res.json())
      .then(data => setUsers(data))
      .catch(err => setError(err.message))
      .finally(() => setLoading(false));
  }, []);

  const filteredUsers = users
    .filter(u => u.name.toLowerCase().includes(searchQuery.toLowerCase()))
    .sort((a, b) => a[sortBy].localeCompare(b[sortBy]));

  const handleDelete = async (id) => {
    await fetch(`/api/users/${id}`, { method: "DELETE" });
    setUsers(prev => prev.filter(u => u.id !== id));
  };

  if (loading) return <div className="spinner" />;
  if (error) return <div className="error">{error}</div>;

  return (
    <div className="user-list-container">
      <h1>Users</h1>
      <input
        value={searchQuery}
        onChange={e => setSearchQuery(e.target.value)}
        placeholder="Search users..."
        className="search-input"
      />
      <select value={sortBy} onChange={e => setSortBy(e.target.value)}>
        <option value="name">Name</option>
        <option value="email">Email</option>
      </select>
      <ul className="user-grid">
        {filteredUsers.map(user => (
          <li key={user.id} className="user-card">
            <img src={user.avatar} alt={user.name} />
            <h3>{user.name}</h3>
            <p>{user.email}</p>
            <button onClick={() => handleDelete(user.id)}>Delete</button>
          </li>
        ))}
      </ul>
    </div>
  );
}

// Problems with this component:
// ❌ 50+ lines mixing data fetching, filtering, sorting, and rendering
// ❌ Can't reuse the user card UI without the fetch logic
// ❌ Can't reuse the fetch logic without the UI
// ❌ Testing requires mocking fetch AND rendering DOM
// ❌ Changing the API response format means editing UI code
// ❌ Changing the card layout means editing data logic code
🔒

Can't Reuse

Want to show users in a dropdown instead of a grid? You'd have to duplicate the entire fetch + filter logic. The UI and data are welded together.

🧪

Can't Test Easily

Testing the card layout requires mocking fetch. Testing the filter logic requires rendering DOM elements. Every test is an integration test.

⚠️

Can't Change Safely

Changing the API endpoint risks breaking the UI. Changing the card design risks breaking the data logic. Every change touches everything.

The single responsibility principle

A component should have one reason to change. The mixed component above has at least four: API changes, filter logic changes, sort logic changes, and UI design changes. Splitting smart and dumb components gives each piece exactly one reason to change.

03

What are Smart Components?

Smart components (container components) are the "brains." They know how to get data, what to do with user actions, and which dumb components to render. They contain logic but minimal markup.

Smart Component — UserListContainerjsx
function UserListContainer() {
  // ── State Management ──────────────────────
  const [users, setUsers] = useState([]);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState(null);
  const [searchQuery, setSearchQuery] = useState("");
  const [sortBy, setSortBy] = useState("name");

  // ── Data Fetching ─────────────────────────
  useEffect(() => {
    setLoading(true);
    fetch("/api/users")
      .then(res => res.json())
      .then(data => setUsers(data))
      .catch(err => setError(err.message))
      .finally(() => setLoading(false));
  }, []);

  // ── Business Logic ────────────────────────
  const filteredUsers = users
    .filter(u => u.name.toLowerCase().includes(searchQuery.toLowerCase()))
    .sort((a, b) => a[sortBy].localeCompare(b[sortBy]));

  const handleDelete = async (id) => {
    await fetch(`/api/users/${id}`, { method: "DELETE" });
    setUsers(prev => prev.filter(u => u.id !== id));
  };

  // ── Render (delegates to dumb components) ─
  if (loading) return <LoadingSpinner />;
  if (error) return <ErrorMessage message={error} />;

  return (
    <UserList
      users={filteredUsers}
      searchQuery={searchQuery}
      sortBy={sortBy}
      onSearchChange={setSearchQuery}
      onSortChange={setSortBy}
      onDelete={handleDelete}
    />
  );
}

// Smart component characteristics:
// ✅ Manages state (useState)
// ✅ Fetches data (useEffect + fetch)
// ✅ Contains business logic (filter, sort)
// ✅ Handles side effects (delete API call)
// ✅ Passes data + callbacks to dumb components
// ✅ Minimal JSX — just composes dumb components
Smart Component Responsibilitiestext
Smart (Container) Component:
┌─────────────────────────────────────────┐
│                                         │
│  • Manages state (useState, useReducer) │
│  • Fetches data (useEffect, React Query)│
│  • Contains business logic
│  • Handles user actions / side effects
│  • Connects to external services
│  • Passes data as props to children
│  • Passes callbacks as props to children
│  • Minimal or no styling
│  • Rarely reusable (specific to a page) │
│                                         │
Think of it as the "brain" or "wiring"
of a feature.                          │
└─────────────────────────────────────────┘

Naming convention

Smart components are often named with a "Container" suffix (UserListContainer, DashboardContainer) or placed in a containers/ directory. With hooks, many teams skip the suffix and use the hook itself as the "smart" layer — more on this in the Modern Perspective section.

04

What are Dumb Components?

Dumb components (presentational components) are the "face." They receive data and callbacks via props and render UI. They don't know where the data comes from, don't fetch anything, and don't manage application state.

Dumb Component — UserListjsx
function UserList({
  users,
  searchQuery,
  sortBy,
  onSearchChange,
  onSortChange,
  onDelete,
}) {
  return (
    <div className="user-list-container">
      <h1>Users</h1>

      <input
        value={searchQuery}
        onChange={e => onSearchChange(e.target.value)}
        placeholder="Search users..."
        className="search-input"
      />

      <select value={sortBy} onChange={e => onSortChange(e.target.value)}>
        <option value="name">Name</option>
        <option value="email">Email</option>
      </select>

      <ul className="user-grid">
        {users.map(user => (
          <UserCard key={user.id} user={user} onDelete={onDelete} />
        ))}
      </ul>
    </div>
  );
}

// Even more granular dumb component:
function UserCard({ user, onDelete }) {
  return (
    <li className="user-card">
      <img src={user.avatar} alt={user.name} />
      <h3>{user.name}</h3>
      <p>{user.email}</p>
      <button onClick={() => onDelete(user.id)}>Delete</button>
    </li>
  );
}

// Dumb component characteristics:
// ✅ Receives ALL data via props
// ✅ No useState for application state (local UI state like "isOpen" is OK)
// ✅ No useEffect for data fetching
// ✅ No API calls or side effects
// ✅ Calls callback props for user actions (onDelete, onSearchChange)
// ✅ Highly reusable — works with any data source
// ✅ Easy to test — pass props, assert output
Dumb Component Responsibilitiestext
Dumb (Presentational) Component:
┌─────────────────────────────────────────┐
│                                         │
│  • Receives data via props
│  • Renders UI (JSX + styling)           │
│  • Calls callback props on user action
│  • May have local UI state (isOpen,     │
isHoveredNOT application state)   │
│  • No data fetching
│  • No business logic
│  • No side effects
│  • Highly reusable across the app
│  • Easy to test (props inUI out)     │
│                                         │
Think of it as a "template" that works
with any data you give it.             │
└─────────────────────────────────────────┘

Local UI state is OK in dumb components

A dumb component can have useState for purely visual concerns: whether a dropdown is open, whether a tooltip is visible, which tab is active. The key distinction is that this state is about how the UI looks, not about application data. If the state would need to be shared with other components or persisted, it belongs in the smart component.

05

Smart vs Dumb Comparison

This is the table interviewers expect you to know. It captures the fundamental differences across every dimension that matters.

DimensionSmart (Container)Dumb (Presentational)
PurposeHow things work (logic, data)How things look (UI, layout)
StateManages application state (useState, useReducer)No app state (local UI state only)
Data fetchingYes (useEffect, React Query, SWR)Never — receives data via props
Side effectsYes (API calls, subscriptions, timers)None
PropsPasses data + callbacks to childrenReceives data + callbacks from parent
JSXMinimal — composes dumb componentsRich — contains all the markup and styling
ReusabilityLow — tied to specific data sourceHigh — works with any data via props
TestabilityHarder — must mock APIs, state, side effectsEasy — pass props, assert rendered output
ExamplesUserListContainer, DashboardContainerUserCard, Button, Modal, DataTable
Also calledContainer, connected, statefulPresentational, pure, stateless
Side-by-Side Summarytext
Smart Component:                  Dumb Component:
┌──────────────────────┐          ┌──────────────────────┐
fetch("/api/users") │          │  <ul>                │
useState(users)     │──props──►│    {users.map(u =>
handleDelete(id)    │          │      <UserCard
filterUsers(query)  │          │        user={u}      │
│                      │◄─events──│        onDelete={..} │
│  <UserList           │          │      />              │
users={filtered}  │          │    )}                │
onDelete={handle} │          │  </ul>               │
│  />                  │          │                      │
└──────────────────────┘          └──────────────────────┘
  Knows HOW to get data            Knows HOW to display data
  Doesn't know HOW to render       Doesn't know WHERE data comes from

The contract between them

The smart component and dumb component communicate through a props interface. The smart component promises to provide certain data and callbacks. The dumb component promises to render that data and call those callbacks on user interaction. Neither knows the other's implementation details.

06

How They Work Together

The power of this pattern comes from composition. Smart components sit at the top of a subtree, managing data. Dumb components form the branches and leaves, rendering UI. Data flows down via props, events flow up via callbacks.

1

Smart component fetches data and manages state

On mount, the smart component calls an API, stores the response in state, and handles loading/error states. It also defines callback functions for user actions (delete, update, filter).

2

Smart component passes data + callbacks as props

The smart component renders one or more dumb components, passing the fetched data and action handlers as props. It decides WHAT to render, not HOW.

3

Dumb component renders UI from props

The dumb component receives props and renders markup. It maps over arrays, applies styles, and displays data. It doesn't know or care where the data came from.

4

User interacts → dumb component calls callback prop

When the user clicks 'Delete,' the dumb component calls onDelete(id) — a callback it received as a prop. It doesn't know what happens next.

5

Smart component handles the action

The callback in the smart component fires: it calls the API, updates state, and React re-renders the dumb component with new props. The cycle repeats.

Complete Flow Examplejsx
// ── SMART COMPONENT (the brain) ──────────────
function TodoContainer() {
  const [todos, setTodos] = useState([]);

  useEffect(() => {
    fetch("/api/todos")
      .then(res => res.json())
      .then(setTodos);
  }, []);

  const handleToggle = (id) => {
    setTodos(prev =>
      prev.map(t => t.id === id ? { ...t, done: !t.done } : t)
    );
  };

  const handleAdd = (text) => {
    const newTodo = { id: Date.now(), text, done: false };
    setTodos(prev => [...prev, newTodo]);
  };

  // Passes everything the UI needs as props
  return (
    <TodoApp
      todos={todos}
      onToggle={handleToggle}
      onAdd={handleAdd}
    />
  );
}

// ── DUMB COMPONENT (the face) ───────────────
function TodoApp({ todos, onToggle, onAdd }) {
  const [input, setInput] = useState(""); // local UI state — OK!

  const handleSubmit = (e) => {
    e.preventDefault();
    if (input.trim()) {
      onAdd(input.trim());  // delegates to smart component
      setInput("");
    }
  };

  return (
    <div className="todo-app">
      <h1>Todos</h1>
      <form onSubmit={handleSubmit}>
        <input
          value={input}
          onChange={e => setInput(e.target.value)}
          placeholder="Add a todo..."
        />
        <button type="submit">Add</button>
      </form>
      <ul>
        {todos.map(todo => (
          <TodoItem key={todo.id} todo={todo} onToggle={onToggle} />
        ))}
      </ul>
    </div>
  );
}

// ── EVEN DUMBER COMPONENT ───────────────────
function TodoItem({ todo, onToggle }) {
  return (
    <li
      className={todo.done ? "done" : ""}
      onClick={() => onToggle(todo.id)}
    >
      {todo.text}
    </li>
  );
}

// Data flow:
// TodoContainer (state, fetch, logic)
//   └─► TodoApp (layout, form UI)
//         └─► TodoItem (single item UI)
//
// Events flow back up via callbacks:
// TodoItem → onToggle → TodoApp → onToggle → TodoContainer

The reusability payoff

Now you can render TodoApp in Storybook with mock data — no API needed. You can use TodoItem in a different feature that has its own data source. You can swap the API in TodoContainer without touching any UI code.

07

Real-World Example

Let's build a user directory page with search, sorting, and delete functionality — split cleanly into smart and dumb components.

Smart: UserDirectoryContainer.tsxjsx
import { useState, useEffect } from "react";
import { UserDirectory } from "./UserDirectory";

export function UserDirectoryContainer() {
  const [users, setUsers] = useState([]);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState(null);
  const [search, setSearch] = useState("");
  const [sortBy, setSortBy] = useState("name");

  useEffect(() => {
    fetch("/api/users")
      .then(res => res.json())
      .then(setUsers)
      .catch(err => setError(err.message))
      .finally(() => setLoading(false));
  }, []);

  const filtered = users
    .filter(u => u.name.toLowerCase().includes(search.toLowerCase()))
    .sort((a, b) => a[sortBy].localeCompare(b[sortBy]));

  const handleDelete = async (id) => {
    await fetch(`/api/users/${id}`, { method: "DELETE" });
    setUsers(prev => prev.filter(u => u.id !== id));
  };

  return (
    <UserDirectory
      users={filtered}
      loading={loading}
      error={error}
      search={search}
      sortBy={sortBy}
      onSearchChange={setSearch}
      onSortChange={setSortBy}
      onDelete={handleDelete}
    />
  );
}
Dumb: UserDirectory.tsxjsx
import { UserCard } from "./UserCard";

export function UserDirectory({
  users,
  loading,
  error,
  search,
  sortBy,
  onSearchChange,
  onSortChange,
  onDelete,
}) {
  if (loading) return <div className="flex justify-center p-8"><Spinner /></div>;
  if (error) return <div className="text-red-600 p-4">Error: {error}</div>;

  return (
    <div className="max-w-4xl mx-auto p-6">
      <h1 className="text-2xl font-bold mb-4">User Directory</h1>

      <div className="flex gap-4 mb-6">
        <input
          value={search}
          onChange={e => onSearchChange(e.target.value)}
          placeholder="Search by name..."
          className="border rounded px-3 py-2 flex-1"
        />
        <select
          value={sortBy}
          onChange={e => onSortChange(e.target.value)}
          className="border rounded px-3 py-2"
        >
          <option value="name">Sort by Name</option>
          <option value="email">Sort by Email</option>
        </select>
      </div>

      {users.length === 0 ? (
        <p className="text-gray-500">No users found.</p>
      ) : (
        <div className="grid grid-cols-1 md:grid-cols-2 gap-4">
          {users.map(user => (
            <UserCard key={user.id} user={user} onDelete={onDelete} />
          ))}
        </div>
      )}
    </div>
  );
}
Dumb: UserCard.tsxjsx
export function UserCard({ user, onDelete }) {
  return (
    <div className="border rounded-lg p-4 flex items-center gap-4">
      <img
        src={user.avatar}
        alt={user.name}
        className="w-12 h-12 rounded-full"
      />
      <div className="flex-1">
        <h3 className="font-semibold">{user.name}</h3>
        <p className="text-gray-500 text-sm">{user.email}</p>
      </div>
      <button
        onClick={() => onDelete(user.id)}
        className="text-red-500 hover:text-red-700 text-sm"
      >
        Delete
      </button>
    </div>
  );
}

// This component is now reusable ANYWHERE:
// • User directory page (with delete)
// • Team members list (without delete — just don't pass onDelete)
// • Search results (with different data source)
// • Storybook (with mock data, no API)
// • Unit tests (render with props, assert output)
AspectMixed (Before)Split (After)
Files1 file, 80+ lines3 files, each 20-30 lines
Reuse UserCardImpossible (embedded in fetch logic)Import and pass props
Test UserCardMock fetch + render full componentPass props, assert output
Change APIEdit the same file as UI codeEdit only the container
Change card designEdit the same file as fetch logicEdit only UserCard
StorybookCan't — needs live APIPass mock data, works instantly

The real win is in testing and Storybook

Dumb components are trivial to test: pass props, check the output. They work in Storybook with zero setup — just provide mock data. This means designers and QA can review components in isolation, without running the full app or needing a backend.

08

Benefits of This Pattern

The smart/dumb split isn't just about code organization — it has concrete, measurable benefits for development speed, code quality, and team collaboration.

✓ Done

Separation of Concerns

Logic and UI change for different reasons. Separating them means a design change never risks breaking data logic, and an API change never risks breaking the layout.

✓ Done

Testability

Dumb components: pass props, assert output. Smart components: mock the API, assert state changes. Each is tested in isolation with focused, simple tests.

✓ Done

Reusability

Dumb components work with any data source. UserCard can be used in a directory, search results, or admin panel — just pass different props. No duplication.

✓ Done

Storybook & Design Systems

Dumb components are perfect for Storybook. Pass mock data, see every state (loading, empty, error, full). Designers review components without running the backend.

Testing Comparisonjsx
// ── Testing a DUMB component (easy) ──────────
test("UserCard renders user name and email", () => {
  render(
    <UserCard
      user={{ id: 1, name: "Alice", email: "alice@test.com", avatar: "/a.jpg" }}
      onDelete={jest.fn()}
    />
  );

  expect(screen.getByText("Alice")).toBeInTheDocument();
  expect(screen.getByText("alice@test.com")).toBeInTheDocument();
});

test("UserCard calls onDelete with user id", () => {
  const onDelete = jest.fn();
  render(
    <UserCard
      user={{ id: 42, name: "Bob", email: "bob@test.com", avatar: "/b.jpg" }}
      onDelete={onDelete}
    />
  );

  fireEvent.click(screen.getByText("Delete"));
  expect(onDelete).toHaveBeenCalledWith(42);
});

// No mocking fetch. No waiting for async. No setup.
// Just props in → assertions out.

// ── Testing a SMART component (focused on logic) ─
test("UserDirectoryContainer filters users by search", async () => {
  // Mock the API
  jest.spyOn(global, "fetch").mockResolvedValue({
    json: () => Promise.resolve([
      { id: 1, name: "Alice" },
      { id: 2, name: "Bob" },
    ]),
  });

  render(<UserDirectoryContainer />);

  // Wait for data to load
  await screen.findByText("Alice");

  // Type in search
  fireEvent.change(screen.getByPlaceholderText("Search by name..."), {
    target: { value: "ali" },
  });

  expect(screen.getByText("Alice")).toBeInTheDocument();
  expect(screen.queryByText("Bob")).not.toBeInTheDocument();
});

The 80/20 rule

In a well-structured app, 80% of components are dumb (easy to test, easy to reuse) and 20% are smart (one per feature/page). This ratio means most of your codebase is simple, predictable, and fast to work with.

09

Modern Perspective: Hooks Era

React Hooks (2019) changed how we implement this pattern — but didn't eliminate the principle. Instead of a container component class, we extract logic into custom hooks. The separation of "logic" and "UI" remains, just with a different mechanism.

Before Hooks: Container Componentjsx
// The "smart" layer was a wrapper component
function UserListContainer() {
  const [users, setUsers] = useState([]);
  // ... fetch logic, filters, handlers ...

  return (
    <UserList
      users={filteredUsers}
      onDelete={handleDelete}
    />
  );
}

// Two components: Container (smart) + UserList (dumb)
// Container exists ONLY to provide data to UserList
After Hooks: Custom Hookjsx
// The "smart" layer is now a custom hook
function useUsers() {
  const [users, setUsers] = useState([]);
  const [loading, setLoading] = useState(true);
  const [search, setSearch] = useState("");

  useEffect(() => {
    fetch("/api/users")
      .then(res => res.json())
      .then(setUsers)
      .finally(() => setLoading(false));
  }, []);

  const filtered = users.filter(u =>
    u.name.toLowerCase().includes(search.toLowerCase())
  );

  const deleteUser = async (id) => {
    await fetch(`/api/users/${id}`, { method: "DELETE" });
    setUsers(prev => prev.filter(u => u.id !== id));
  };

  return { users: filtered, loading, search, setSearch, deleteUser };
}

// The component uses the hook — no separate container needed
function UserDirectory() {
  const { users, loading, search, setSearch, deleteUser } = useUsers();

  if (loading) return <Spinner />;

  return (
    <div>
      <SearchInput value={search} onChange={setSearch} />
      <UserList users={users} onDelete={deleteUser} />
    </div>
  );
}

// The separation still exists:
// useUsers()     → smart layer (data, logic, side effects)
// UserDirectory  → composition layer (which components to render)
// UserList       → dumb layer (pure UI from props)
// SearchInput    → dumb layer (pure UI from props)
Container Pattern (Pre-Hooks)Custom Hook Pattern (Post-Hooks)
Smart layerContainer component (UserListContainer)Custom hook (useUsers)
Dumb layerPresentational component (UserList)Same — presentational component (UserList)
BoilerplateExtra wrapper component just for dataHook is called directly — no wrapper
Reuse logicRender props or HOCs (complex)Call the hook in any component (simple)
Testing logicRender container, mock childrenTest hook with renderHook() — no DOM needed
PrincipleSeparate logic from UISame principle, better ergonomics

Dan Abramov's update (2019)

Dan Abramov, who popularized the container/presentational pattern, later wrote that he no longer recommends splitting components this way as a strict rule. With hooks, you can extract logic without a wrapper component. But the principle — don't mix data fetching with UI rendering — is still valuable. Think of it as a guideline, not a law.

10

When NOT to Use This Pattern

Like any pattern, smart/dumb separation can be over-applied. Here's when the strict split adds complexity without benefit.

When Separation is Overkilltext
Over-separated: Simple toggle button

  // Container just to manage one boolean?
  function ToggleContainer() {
    const [isOn, setIsOn] = useState(false);
    return <Toggle isOn={isOn} onToggle={() => setIsOn(!isOn)} />;
  }

  function Toggle({ isOn, onToggle }) {
    return <button onClick={onToggle}>{isOn ? "ON" : "OFF"}</button>;
  }

  // This is pointless. The state IS the component.

Just write it as one component:

  function Toggle() {
    const [isOn, setIsOn] = useState(false);
    return (
      <button onClick={() => setIsOn(!isOn)}>
        {isOn ? "ON" : "OFF"}
      </button>
    );
  }

Rules for when NOT to split:
Component is small (< 30 lines)
State is purely local UI state (toggle, hover, open/close)
Component won't be reused with different data sources
The "container" would just pass props through unchanged
You're adding a wrapper component with no real logic
Split (Smart + Dumb)Don't Split (Single Component)
Component fetches data from APIComponent only has local UI state
UI needs to be reused with different dataUI is specific to one place
Complex business logic (filter, sort, transform)Simple display with minimal logic
Multiple developers work on logic vs UIOne developer owns the whole thing
Component is 50+ lines with mixed concernsComponent is under 30 lines total

The pragmatic rule

Split when the component has two distinct reasons to change: data logic AND UI presentation. Don't split when the component is simple enough that one developer can understand it in 30 seconds. The goal is clarity, not adherence to a pattern.

11

Common Mistakes

These mistakes show up in code reviews and interviews. They reveal a surface-level understanding of the pattern without grasping the underlying principle.

🧠

Putting business logic in dumb components

A 'presentational' component that filters data, calls APIs, or transforms state. It's dumb in name only — it still has all the logic problems of a mixed component.

If a component filters, sorts, fetches, or transforms data, that logic belongs in a smart component or custom hook. Dumb components receive final, ready-to-render data via props.

📦

Creating containers that just pass props through

A container component that calls a hook and passes every return value as props to a child — adding a layer of indirection with zero benefit.

If the container adds no logic beyond calling a hook, let the component call the hook directly. The hook IS the smart layer. You don't need a wrapper component too.

🔬

Over-splitting small components

Creating a ToggleContainer + Toggle for a 10-line component with one boolean state. The separation adds files and complexity without improving reusability or testability.

Only split when there's a real benefit: the UI is reused elsewhere, the logic is complex, or different developers own logic vs UI. Small, simple components don't need splitting.

🏷️

Naming without understanding

Calling components 'Container' or 'Presentational' without actually separating concerns. The names become meaningless labels instead of architectural signals.

The name should reflect reality. If UserListContainer still has 50 lines of JSX and styling, it's not a container — it's a mixed component. Refactor the code, not just the name.

🔗

Dumb components with hidden dependencies

A 'presentational' component that imports a context, reads from a store, or accesses window/localStorage. It looks dumb but has invisible external dependencies.

Dumb components should depend only on their props. If they need data from context or a store, that data should be passed as a prop from the smart parent. This keeps them truly portable.

⚙️

Ignoring hooks as the modern smart layer

Still creating wrapper container components in 2024+ when a custom hook would be simpler. The container pattern was necessary before hooks — now it's often unnecessary boilerplate.

Use custom hooks (useUsers, useAuth, useDashboard) as the smart layer. The component calls the hook and renders dumb children. No wrapper component needed.

12

Interview Questions

These questions test whether you understand the principle behind the pattern, not just the terminology.

Q:What are smart and dumb components?

A: Smart (container) components manage state, fetch data, handle business logic, and pass results to children via props. Dumb (presentational) components receive data via props and render UI — they don't know where data comes from or what happens on user actions. The separation follows the single responsibility principle: smart components handle 'how things work,' dumb components handle 'how things look.'

Q:Why separate logic from UI in components?

A: Three main benefits: (1) Reusability — dumb components work with any data source, so UserCard can be used in a directory, search results, or admin panel. (2) Testability — dumb components are trivial to test (pass props, assert output) without mocking APIs. (3) Maintainability — changing the API doesn't risk breaking the UI, and changing the design doesn't risk breaking data logic. Each piece has one reason to change.

Q:Is the container/presentational pattern still relevant with hooks?

A: The principle is still relevant — separating data logic from UI rendering is always valuable. But the implementation changed. Before hooks, you needed a wrapper container component. Now, you extract logic into custom hooks (useUsers, useAuth). The hook IS the smart layer. Dan Abramov himself said he no longer recommends the strict container/presentational split as a rule, but the underlying idea of separating concerns remains important.

Q:Can a dumb component have state?

A: Yes — local UI state. A dumb component can use useState for purely visual concerns: whether a dropdown is open, which tab is active, whether a tooltip is visible. The key distinction: this state is about how the UI looks, not about application data. If the state needs to be shared with other components, persisted, or derived from an API, it belongs in the smart layer.

Q:How do you decide whether to split a component?

A: Split when: (1) the component fetches data AND renders complex UI, (2) the UI needs to be reused with different data sources, (3) the component is 50+ lines mixing logic and markup, (4) you want to test logic and UI independently. Don't split when: the component is small (< 30 lines), state is purely local UI, or the 'container' would just pass props through unchanged.

Q:How do custom hooks replace container components?

A: A custom hook extracts the 'smart' logic (state, data fetching, business logic) into a reusable function. Instead of: ContainerComponent → PresentationalComponent, you have: useDataHook() called inside the component. The hook returns data and callbacks, the component renders UI. Same separation, less boilerplate. The hook can also be reused across multiple components — something container components couldn't do easily.

Q:What ratio of smart to dumb components is ideal?

A: Roughly 20% smart, 80% dumb. Most components in a well-structured app are presentational — they receive props and render UI. Smart components (or custom hooks) sit at feature boundaries: one per page or feature, managing data for a subtree of dumb components. If your ratio is inverted (80% smart), most components are doing too much.

Q:How does this pattern relate to React Server Components?

A: React Server Components (RSC) add a new dimension. Server components can fetch data directly (they're inherently 'smart' on the server). Client components handle interactivity. The pattern maps naturally: server components fetch and pass data as props to client components that render interactive UI. The principle — separate data acquisition from UI rendering — is reinforced by RSC, just at a different boundary (server vs client).

13

Practice Section

These scenarios test your ability to apply the smart/dumb principle to real code decisions.

1

A component fetches a list of products from an API, filters them by category, sorts by price, and renders each product as a card with an image, title, price, and 'Add to Cart' button. The component is 120 lines long.

How would you split this component?

Answer: Extract a useProducts(category) custom hook that handles fetching, filtering, and sorting — returns { products, loading, error }. Create a ProductCard dumb component that receives { product, onAddToCart } props and renders the card UI. Create a ProductList dumb component that maps over products and renders ProductCards. The page component calls useProducts(), handles onAddToCart, and renders <ProductList>. Three focused pieces instead of one 120-line monolith.

2

A junior developer creates a 'dumb' NotificationBell component. But inside it, they use useContext(NotificationContext) to read the notification count and useEffect to set up a WebSocket listener for real-time updates.

What's wrong and how would you fix it?

Answer: This component isn't dumb — it has external dependencies (context, WebSocket). A truly dumb NotificationBell should receive { count, onClear } as props and just render the bell icon with a badge. The context reading and WebSocket setup belong in a smart parent or a useNotifications() hook. Fix: extract the logic into the hook, have the parent call it, and pass count as a prop to NotificationBell. Now the bell is reusable and testable without mocking context or WebSocket.

3

A team has 15 container components that each wrap exactly one presentational component. Each container just calls a custom hook and passes the return values as props. The containers add no additional logic.

Are these containers necessary?

Answer: No — they're unnecessary boilerplate. If a container's only job is calling a hook and forwarding props, the presentational component can call the hook directly. The hook IS the smart layer; you don't need a wrapper component too. Remove the 15 containers, have each component call its hook directly, and keep the dumb sub-components (UserCard, ProductCard) as pure presentational components that receive props.

4

You're building a dashboard with 6 widgets: StatsCard, RevenueChart, RecentOrders, TopProducts, UserActivity, and SystemHealth. Each widget needs different API data. Currently, a single DashboardContainer fetches all 6 datasets and passes them as props.

How would you improve this architecture?

Answer: Give each widget its own smart layer. Create hooks: useStats(), useRevenue(), useRecentOrders(), etc. Each widget component calls its own hook. This way: (1) widgets load independently — a slow API doesn't block other widgets, (2) widgets are reusable on other pages, (3) the dashboard page just composes widgets without managing any data. The DashboardContainer becomes a simple layout component: <Dashboard><StatsCard /><RevenueChart />...</Dashboard>.

5

A DataTable component receives rows as props (dumb) but also needs to handle column sorting, pagination, row selection, and inline editing internally. The component is growing to 200+ lines.

Is this still a dumb component? How would you handle the complexity?

Answer: It's a dumb component with complex local UI state — which is valid. Sorting, pagination, and selection are UI concerns, not business logic. But 200+ lines is too much for one component. Split it: extract useTableState() hook for sort/pagination/selection logic, create TableHeader (sortable columns), TableBody (rows), TablePagination (page controls) as sub-components. The parent DataTable composes them. Each piece stays dumb (props-driven) but focused. The data source (API, static array) remains external.

14

Cheat Sheet

Quick-reference summary for interviews and code reviews.

Quick Revision Cheat Sheet

Smart component: Manages state, fetches data, handles logic, passes props to children

Dumb component: Receives props, renders UI, calls callback props on user action

Also called: Smart = Container, Stateful. Dumb = Presentational, Pure, Stateless

Smart component JSX: Minimal — just composes dumb components and passes props

Dumb component state: Local UI state only (isOpen, activeTab) — never application data

Modern smart layer: Custom hooks (useUsers, useAuth) replace container components

Ideal ratio: ~20% smart (hooks/containers), ~80% dumb (presentational)

When to split: Component fetches data AND renders complex UI, or UI needs reuse with different data

When NOT to split: Component is < 30 lines, state is purely local UI, no reuse needed

Testing dumb: Pass props → assert rendered output. No mocking needed.

Testing smart: Mock API → assert state changes. Or test hook with renderHook().

Reusability: Dumb components are highly reusable (any data source). Smart components are not.

Dan Abramov's update: No longer recommends strict split as a rule, but the principle still holds

Server Components: RSC reinforces the pattern: server = data fetching, client = interactive UI