Skip to content
Laviyx Learn

Try

React course menu

React Project: Build a To-Do App Step by Step

Build a complete React to-do app: planning components and state, adding tasks with a controlled form, toggling and deleting, editing tasks inline, filtering all/active/completed, counts and clear completed, and saving to localStorage with a custom hook.

Intermediate15 min readUpdated
On this page

This project combines the core of the course (components, props, state, events, forms, lists and a custom hook) into a complete to-do app. Each step adds one feature and runs live below its code.

Step 1: Plan#

Data: a list of tasks, each { id, text, done }, plus the current filter.

Components:

Text
App                 owns all state
├── AddTaskForm     controlled input; calls onAdd(text)
├── FilterBar       All / Active / Completed buttons
├── TaskList
│   └── TaskItem    checkbox, text (or edit box), delete button
└── Footer          "3 tasks left", Clear completed

The tasks live in App, because the form, the list and the footer all need them (lifting state up).

Step 2: Show and add tasks#

JSX
import { useState } from "react";

function AddTaskForm({ onAdd }) {
  const [text, setText] = useState("");
  function handleSubmit(e) {
    e.preventDefault();
    const trimmed = text.trim();
    if (!trimmed) return;
    onAdd(trimmed);
    setText("");
  }
  return (
    <form onSubmit={handleSubmit}>
      <input value={text} onChange={(e) => setText(e.target.value)} placeholder="What needs doing?" />
      <button type="submit">Add</button>
    </form>
  );
}

export default function App() {
  const [tasks, setTasks] = useState([
    { id: 1, text: "Buy vegetables", done: false },
    { id: 2, text: "Finish React lesson", done: true },
  ]);

  function addTask(text) {
    setTasks([...tasks, { id: Date.now(), text, done: false }]);
  }

  return (
    <div>
      <h3>My tasks</h3>
      <AddTaskForm onAdd={addTask} />
      <ul>
        {tasks.map((t) => <li key={t.id}>{t.text}</li>)}
      </ul>
    </div>
  );
}
Result

Date.now() gives a unique-enough id for a personal app; crypto.randomUUID() is a more robust choice when ids must never collide.

Step 3: Toggle and delete#

JSX
import { useState } from "react";

function TaskItem({ task, onToggle, onDelete }) {
  return (
    <li>
      <label style={{ textDecoration: task.done ? "line-through" : "none", color: task.done ? "#888" : "inherit" }}>
        <input type="checkbox" checked={task.done} onChange={() => onToggle(task.id)} /> {task.text}
      </label>{" "}
      <button onClick={() => onDelete(task.id)} aria-label={`Delete ${task.text}`}>✕</button>
    </li>
  );
}

export default function App() {
  const [tasks, setTasks] = useState([
    { id: 1, text: "Buy vegetables", done: false },
    { id: 2, text: "Finish React lesson", done: true },
    { id: 3, text: "Call grandparents", done: false },
  ]);

  const toggleTask = (id) => setTasks(tasks.map((t) => (t.id === id ? { ...t, done: !t.done } : t)));
  const deleteTask = (id) => setTasks(tasks.filter((t) => t.id !== id));

  return (
    <ul>
      {tasks.map((t) => (
        <TaskItem key={t.id} task={t} onToggle={toggleTask} onDelete={deleteTask} />
      ))}
    </ul>
  );
}
Result

map with a spread copy updates one task; filter removes one. Neither changes the original array, as immutable updates require.

Step 4: The complete app#

The final version adds inline editing (double-click a task), filters, a count of remaining tasks, "Clear completed", and saving to localStorage through a custom hook. It moves all task updates into a reducer, since there are now many kinds of change.

CSS
.app { max-width: 380px; font-family: system-ui, sans-serif; }
.app form { display: flex; gap: 6px; }
.app form input { flex: 1; padding: 6px; }
.filters button { margin-right: 4px; }
.filters button.active { font-weight: bold; text-decoration: underline; }
.tasks { list-style: none; padding: 0; }
.tasks li { display: flex; align-items: center; gap: 6px; padding: 4px 0; border-bottom: 1px solid #eee; }
.tasks li span { flex: 1; cursor: text; }
.tasks li.done span { text-decoration: line-through; color: #888; }
.footer { display: flex; justify-content: space-between; color: #555; font-size: 14px; }
JSX
import { useEffect, useReducer, useState } from "react";

function useLocalStorageReducer(key, reducer, initial) {
  const [state, dispatch] = useReducer(reducer, initial, (fallback) => {
    try {
      const saved = localStorage.getItem(key);
      return saved ? JSON.parse(saved) : fallback;
    } catch {
      return fallback;
    }
  });
  useEffect(() => {
    try {
      localStorage.setItem(key, JSON.stringify(state));
    } catch {
      // storage unavailable: keep working in memory
    }
  }, [key, state]);
  return [state, dispatch];
}

function tasksReducer(tasks, action) {
  switch (action.type) {
    case "added":
      return [...tasks, { id: action.id, text: action.text, done: false }];
    case "toggled":
      return tasks.map((t) => (t.id === action.id ? { ...t, done: !t.done } : t));
    case "edited":
      return tasks.map((t) => (t.id === action.id ? { ...t, text: action.text } : t));
    case "deleted":
      return tasks.filter((t) => t.id !== action.id);
    case "clearedCompleted":
      return tasks.filter((t) => !t.done);
    default:
      throw new Error(`Unknown action: ${action.type}`);
  }
}

const FILTERS = {
  All: () => true,
  Active: (t) => !t.done,
  Completed: (t) => t.done,
};

function TaskItem({ task, dispatch }) {
  const [editing, setEditing] = useState(false);
  const [draft, setDraft] = useState(task.text);

  function save() {
    const text = draft.trim();
    if (text) dispatch({ type: "edited", id: task.id, text });
    else setDraft(task.text);
    setEditing(false);
  }

  return (
    <li className={task.done ? "done" : ""}>
      <input type="checkbox" checked={task.done} onChange={() => dispatch({ type: "toggled", id: task.id })} />
      {editing ? (
        <input
          autoFocus
          value={draft}
          onChange={(e) => setDraft(e.target.value)}
          onBlur={save}
          onKeyDown={(e) => {
            if (e.key === "Enter") save();
            if (e.key === "Escape") { setDraft(task.text); setEditing(false); }
          }}
        />
      ) : (
        <span onDoubleClick={() => setEditing(true)} title="Double-click to edit">{task.text}</span>
      )}
      <button onClick={() => dispatch({ type: "deleted", id: task.id })} aria-label={`Delete ${task.text}`}>✕</button>
    </li>
  );
}

export default function App() {
  const [tasks, dispatch] = useLocalStorageReducer("todo-tasks", tasksReducer, [
    { id: 1, text: "Buy vegetables", done: false },
    { id: 2, text: "Finish React lesson", done: true },
    { id: 3, text: "Call grandparents", done: false },
  ]);
  const [filter, setFilter] = useState("All");
  const [text, setText] = useState("");

  function handleSubmit(e) {
    e.preventDefault();
    if (!text.trim()) return;
    dispatch({ type: "added", id: Date.now(), text: text.trim() });
    setText("");
  }

  const visible = tasks.filter(FILTERS[filter]);
  const left = tasks.filter((t) => !t.done).length;

  return (
    <div className="app">
      <h3>My tasks</h3>
      <form onSubmit={handleSubmit}>
        <input value={text} onChange={(e) => setText(e.target.value)} placeholder="What needs doing?" />
        <button type="submit">Add</button>
      </form>
      <p className="filters">
        {Object.keys(FILTERS).map((name) => (
          <button key={name} className={name === filter ? "active" : ""} onClick={() => setFilter(name)}>
            {name}
          </button>
        ))}
      </p>
      {visible.length === 0 ? (
        <p>Nothing here.</p>
      ) : (
        <ul className="tasks">
          {visible.map((t) => <TaskItem key={t.id} task={t} dispatch={dispatch} />)}
        </ul>
      )}
      <div className="footer">
        <span>{left} {left === 1 ? "task" : "tasks"} left</span>
        {tasks.some((t) => t.done) && (
          <button onClick={() => dispatch({ type: "clearedCompleted" })}>Clear completed</button>
        )}
      </div>
    </div>
  );
}
Result

Try it: add a task, tick some off, switch filters, double-click a task to edit it (Enter saves, Escape cancels), and clear completed tasks.

Notes on the design:

  • One reducer holds every way the task list can change, so the components only dispatch actions.
  • Derived values (visible, left) are calculated from state during rendering, never stored separately.
  • Editing state (editing, draft) lives in TaskItem, because no other component needs it.
  • useReducer's third argument is an initialiser function: it runs once to load saved tasks.
  • Persistence: in your own project, tasks survive a page reload. The preview frame on this site cannot use localStorage (browsers block it in sandboxed frames), so the hook falls back to keeping tasks in memory there.

Ideas to extend it#

  • Due dates and sorting by date.
  • Drag-and-drop reordering.
  • A "Mark all as done" checkbox.
  • Moving it to TypeScript with a Task type and a typed Action union.
  • Syncing tasks with a back-end API (see Fetching Data).

Practice exercise

Add a "Mark all as done" button to the final app that marks every task done, and is hidden when all tasks are already done.

Show hint

Add a reducer case "allCompleted" that returns tasks.map((t) => ({ ...t, done: true })), and show the button only when left > 0.

Show solution

Add this case to tasksReducer:

JSX
case "allCompleted":
  return tasks.map((t) => ({ ...t, done: true }));

and this button in App, for example inside the footer:

JSX
{left > 0 && <button onClick={() => dispatch({ type: "allCompleted" })}>Mark all as done</button>}

Next project: Product Catalogue with Cart.

Finished this lesson?