Back to all notes

Presentational vs Container Components: Separate How It Looks from How It Works

Extract behavior into hooks, render with pure components, and keep your features composable.

3 min read

Mixing state management into rendering logic gives me the components that are hardest to reuse and test. So I keep them apart. Presentational components render from props. Containers own the state and pass it down.

Where the tangle starts

TodoContainer stores the todos, toggles them, and renders every list item itself. Any new behavior, filters or persistence, lands in this same file.

import { useState } from "react";

function TodoContainer() {
  const [todos, setTodos] = useState([
    { id: 1, text: "Learn React", completed: false },
    { id: 2, text: "Practice design patterns", completed: true },
    { id: 3, text: "Build awesome apps", completed: false },
  ]);

  const toggleTodo = (id) => {
    setTodos(todos.map(t =>
      t.id === id ? { ...t, completed: !t.completed } : t
    ));
  };

  return (
    <ul>
      {todos.map((todo) => (
        <li
          key={todo.id}
          style={{ textDecoration: todo.completed ? "line-through" : "none" }}
          onClick={() => toggleTodo(todo.id)}
        >
          {todo.text}
        </li>
      ))}
    </ul>
  );
}

function App() {
  return (
    <div>
      <h1>Presentational vs Container Components</h1>
      <TodoContainer />
    </div>
  );
}

export default App;

Data and markup tangled in one function, and it only grows.

A hook for the behavior, a component for the list

I pull the state and mutations into a useTodos hook and hand rendering to a pure TodoList that takes todos and onToggle.

import { useState } from "react";

function useTodos() {
  const [todos, setTodos] = useState([
    { id: 1, text: "Learn React", completed: false },
    { id: 2, text: "Practice design patterns", completed: true },
    { id: 3, text: "Build awesome apps", completed: false },
  ]);

  const toggleTodo = (id) => {
    setTodos(prev =>
      prev.map(todo =>
        todo.id === id ? { ...todo, completed: !todo.completed } : todo
      )
    );
  };

  return { todos, toggleTodo };
}

function TodoList({ todos, onToggle }) {
  return (
    <ul>
      {todos.map((todo) => (
        <li
          key={todo.id}
          style={{
            textDecoration: todo.completed ? "line-through" : "none",
            cursor: "pointer",
          }}
          onClick={() => onToggle(todo.id)}
        >
          {todo.text}
        </li>
      ))}
    </ul>
  );
}

function TodoContainer() {
  const { todos, toggleTodo } = useTodos();
  return (
    <div>
      <h2>Todo List</h2>
      <TodoList todos={todos} onToggle={toggleTodo} />
    </div>
  );
}

function App() {
  return (
    <div>
      <h1>Presentational vs Container Components</h1>
      <TodoContainer />
    </div>
  );
}

export default App;

Todo state and the toggle move into the hook. TodoList accepts todos and onToggle as props. A container pulls from the hook and renders <TodoList />, with the container answering for data and the list for UI. Toggling still works, and the list itself stays stateless.

Stripped down, the tangled version is inline mutation next to inline rendering.

setTodos(todos.map(/* inline mutation logic */))
<ul>{todos.map(/* inline render */)}</ul>

The extracted version is a hook plus a handoff.

function useTodos() { /* state + toggle */ }
<TodoList todos={todos} onToggle={toggleTodo} />

TodoList drops into any container now, and UI and behavior can be tested independently.

The purity is what makes the split stick. Presentational components get no side effects, and container props stay minimal, just the functions and the exact data required. When the UI needs variations, a small prop like variant beats branching inside the component.