← Back to Tutorials

Building a Simple To-Do App with React (Beginner-Friendly Guide)

reacttodo-appbeginnerjavascriptfrontend-developmentreact-hooksstate-managementweb-development

Building a Simple To-Do App with React (Beginner-Friendly Guide)

This tutorial walks you through building a small but real React to-do application from scratch. You will create a project, understand the React concepts involved, implement add/complete/delete functionality, persist tasks in localStorage, and structure your code in a clean, beginner-friendly way.


What You Will Build

A simple to-do app that can:

You’ll also learn:


Prerequisites

You should have:

Check your Node and npm versions:

node -v
npm -v

If those commands fail, install Node.js from https://nodejs.org/.


Step 1: Create a New React Project

There are multiple ways to start a React app. A modern and fast option is Vite.

Create a new project:

npm create vite@latest todo-react -- --template react

Go into the folder and install dependencies:

cd todo-react
npm install

Start the development server:

npm run dev

Vite will print a local URL (often http://localhost:5173). Open it in your browser.


Step 2: Understand the Default Project Structure

Inside your project you’ll see something like:

React works by rendering components (functions) into the DOM. Vite’s main.jsx typically looks like:

import React from 'react'
import ReactDOM from 'react-dom/client'
import App from './App.jsx'
import './index.css'

ReactDOM.createRoot(document.getElementById('root')).render(
  <React.StrictMode>
    <App />
  </React.StrictMode>,
)

This means: “Render <App /> inside the element with id root.”


Step 3: Clean Up the Starter App

Open src/App.jsx and replace it with a minimal starting point:

import { useEffect, useMemo, useState } from "react";
import "./App.css";

export default function App() {
  return (
    <main className="app">
      <h1>To-Do App</h1>
    </main>
  );
}

Create src/App.css (or edit it if it exists) and add some basic styling:

.app {
  max-width: 720px;
  margin: 48px auto;
  padding: 0 16px;
  font-family: system-ui, -apple-system, Segoe UI, Roboto, Arial, sans-serif;
}

h1 {
  margin-bottom: 16px;
}

.card {
  background: #ffffff;
  border: 1px solid #e6e6e6;
  border-radius: 12px;
  padding: 16px;
}

.row {
  display: flex;
  gap: 8px;
}

.row > * {
  flex: 1;
}

button {
  flex: 0 0 auto;
  padding: 10px 12px;
  border-radius: 10px;
  border: 1px solid #d0d0d0;
  background: #f7f7f7;
  cursor: pointer;
}

button:hover {
  background: #efefef;
}

input {
  padding: 10px 12px;
  border-radius: 10px;
  border: 1px solid #d0d0d0;
}

.todo-list {
  list-style: none;
  padding: 0;
  margin: 16px 0 0;
  display: grid;
  gap: 10px;
}

.todo-item {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 12px;
  padding: 12px;
  border: 1px solid #e6e6e6;
  border-radius: 12px;
}

.todo-left {
  display: flex;
  align-items: center;
  gap: 10px;
  min-width: 0;
}

.todo-title {
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}

.todo-title.completed {
  text-decoration: line-through;
  color: #777;
}

.filters {
  display: flex;
  gap: 8px;
  margin-top: 12px;
  flex-wrap: wrap;
}

.filters button[aria-pressed="true"] {
  border-color: #333;
  background: #eaeaea;
}

.muted {
  color: #666;
  font-size: 14px;
}

Step 4: Plan the Data Model (What a “Todo” Looks Like)

Before writing logic, decide what a task object should contain. A good beginner-friendly shape:

{
  id: "some-unique-id",
  title: "Buy milk",
  completed: false,
  createdAt: 1700000000000
}

Why these fields?


Step 5: Add State with useState

React state is how your UI “remembers” values over time. When state changes, React re-renders the component.

In src/App.jsx, add state for:

Replace App.jsx with:

import { useEffect, useMemo, useState } from "react";
import "./App.css";

const FILTERS = ["all", "active", "completed"];

export default function App() {
  const [title, setTitle] = useState("");
  const [todos, setTodos] = useState([]);
  const [filter, setFilter] = useState("all");

  return (
    <main className="app">
      <h1>To-Do App</h1>

      <section className="card">
        <form className="row">
          <input
            value={title}
            onChange={(e) => setTitle(e.target.value)}
            placeholder="Add a task..."
          />
          <button type="submit">Add</button>
        </form>

        <div className="filters" role="group" aria-label="Filters">
          {FILTERS.map((f) => (
            <button
              key={f}
              type="button"
              aria-pressed={filter === f}
              onClick={() => setFilter(f)}
            >
              {f[0].toUpperCase() + f.slice(1)}
            </button>
          ))}
        </div>

        <p className="muted">Tasks will appear here.</p>
      </section>
    </main>
  );
}

What’s happening here?

At this point, the form doesn’t add tasks yet. Next we implement that.


Step 6: Handle Form Submission (Add a Todo)

In HTML, submitting a form reloads the page by default. In a React SPA, you usually prevent that and handle submission in JavaScript.

Add a helper to create unique IDs. For a beginner tutorial, crypto.randomUUID() is simple and built into modern browsers.

Update App.jsx:

import { useEffect, useMemo, useState } from "react";
import "./App.css";

const FILTERS = ["all", "active", "completed"];

function makeTodo(title) {
  return {
    id: crypto.randomUUID(),
    title: title.trim(),
    completed: false,
    createdAt: Date.now(),
  };
}

export default function App() {
  const [title, setTitle] = useState("");
  const [todos, setTodos] = useState([]);
  const [filter, setFilter] = useState("all");

  function handleSubmit(e) {
    e.preventDefault();

    const trimmed = title.trim();
    if (!trimmed) return;

    const newTodo = makeTodo(trimmed);

    // Important: never mutate state arrays directly (no todos.push)
    // Create a new array instead:
    setTodos((prev) => [newTodo, ...prev]);

    // Clear the input
    setTitle("");
  }

  return (
    <main className="app">
      <h1>To-Do App</h1>

      <section className="card">
        <form className="row" onSubmit={handleSubmit}>
          <input
            value={title}
            onChange={(e) => setTitle(e.target.value)}
            placeholder="Add a task..."
          />
          <button type="submit">Add</button>
        </form>

        <div className="filters" role="group" aria-label="Filters">
          {FILTERS.map((f) => (
            <button
              key={f}
              type="button"
              aria-pressed={filter === f}
              onClick={() => setFilter(f)}
            >
              {f[0].toUpperCase() + f.slice(1)}
            </button>
          ))}
        </div>

        <p className="muted">
          {todos.length === 0 ? "No tasks yet. Add one above." : "Your tasks:"}
        </p>
      </section>
    </main>
  );
}

Why not todos.push(newTodo)?

React state should be treated as immutable. If you mutate the existing array, React may not detect a change correctly, and you can create confusing bugs. Using [newTodo, ...prev] creates a brand-new array.


Step 7: Render the Todo List

Now we want to display tasks. In React you typically use map() to turn an array into elements.

Add this list under the “Your tasks” text:

<ul className="todo-list">
  {todos.map((t) => (
    <li key={t.id} className="todo-item">
      <div className="todo-left">
        <input type="checkbox" checked={t.completed} readOnly />
        <span className={"todo-title" + (t.completed ? " completed" : "")}>
          {t.title}
        </span>
      </div>
      <button type="button">Delete</button>
    </li>
  ))}
</ul>

Your App.jsx should now include that section. Full component so far:

import { useEffect, useMemo, useState } from "react";
import "./App.css";

const FILTERS = ["all", "active", "completed"];

function makeTodo(title) {
  return {
    id: crypto.randomUUID(),
    title: title.trim(),
    completed: false,
    createdAt: Date.now(),
  };
}

export default function App() {
  const [title, setTitle] = useState("");
  const [todos, setTodos] = useState([]);
  const [filter, setFilter] = useState("all");

  function handleSubmit(e) {
    e.preventDefault();
    const trimmed = title.trim();
    if (!trimmed) return;

    setTodos((prev) => [makeTodo(trimmed), ...prev]);
    setTitle("");
  }

  return (
    <main className="app">
      <h1>To-Do App</h1>

      <section className="card">
        <form className="row" onSubmit={handleSubmit}>
          <input
            value={title}
            onChange={(e) => setTitle(e.target.value)}
            placeholder="Add a task..."
          />
          <button type="submit">Add</button>
        </form>

        <div className="filters" role="group" aria-label="Filters">
          {FILTERS.map((f) => (
            <button
              key={f}
              type="button"
              aria-pressed={filter === f}
              onClick={() => setFilter(f)}
            >
              {f[0].toUpperCase() + f.slice(1)}
            </button>
          ))}
        </div>

        <ul className="todo-list">
          {todos.map((t) => (
            <li key={t.id} className="todo-item">
              <div className="todo-left">
                <input type="checkbox" checked={t.completed} readOnly />
                <span className={"todo-title" + (t.completed ? " completed" : "")}>
                  {t.title}
                </span>
              </div>
              <button type="button">Delete</button>
            </li>
          ))}
        </ul>

        {todos.length === 0 && (
          <p className="muted">No tasks yet. Add one above.</p>
        )}
      </section>
    </main>
  );
}

The importance of key={t.id}

React uses keys to understand which item is which between renders. If you delete an item, React needs stable keys to avoid mixing up DOM elements and state. Using a unique id is the standard approach.


Step 8: Toggle Completion (Checkbox)

Right now the checkbox is read-only. Let’s make it interactive.

Add a function:

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

Why map() and { ...t, completed: !t.completed }?

Update the checkbox:

<input
  type="checkbox"
  checked={t.completed}
  onChange={() => toggleTodo(t.id)}
/>

Now clicking the checkbox will toggle the completed state.


Step 9: Delete a Todo

Add a function:

function deleteTodo(id) {
  setTodos((prev) => prev.filter((t) => t.id !== id));
}

Update the Delete button:

<button type="button" onClick={() => deleteTodo(t.id)}>
  Delete
</button>

filter() returns a new array containing everything except the item to delete. Again: no mutation.


Step 10: Add Filtering (All / Active / Completed)

You already have filter buttons, but they don’t affect the list. We’ll compute a derived list based on todos and filter.

Derived data: useMemo

You can compute filtered todos directly in render, but useMemo teaches a valuable concept: “only recompute when inputs change.”

Add:

const visibleTodos = useMemo(() => {
  if (filter === "active") return todos.filter((t) => !t.completed);
  if (filter === "completed") return todos.filter((t) => t.completed);
  return todos;
}, [todos, filter]);

Then render visibleTodos instead of todos:

{visibleTodos.map((t) => (
  ...
))}

Why filtering is “derived state”

A common beginner mistake is to store both todos and filteredTodos in state. That duplicates data and can get out of sync. Instead, keep the source of truth (todos) and compute what you need (visibleTodos) from it.


Step 11: Persist Todos with localStorage

If you refresh the page now, your tasks disappear. To persist them, you can store them in localStorage.

How localStorage works

We need two pieces:

  1. Load stored todos when the app starts.
  2. Save todos whenever they change.

1) Load on first render

A nice pattern is to use a lazy initializer for useState, so reading localStorage happens once:

const [todos, setTodos] = useState(() => {
  const raw = localStorage.getItem("todos:v1");
  if (!raw) return [];
  try {
    const parsed = JSON.parse(raw);
    return Array.isArray(parsed) ? parsed : [];
  } catch {
    return [];
  }
});

This is better than reading localStorage at the top level because it avoids doing work on every render.

2) Save whenever todos changes

Use useEffect:

useEffect(() => {
  localStorage.setItem("todos:v1", JSON.stringify(todos));
}, [todos]);

This effect runs after render whenever todos changes.


Step 12: Final App.jsx (Complete Working Version)

Replace src/App.jsx with the following complete code:

import { useEffect, useMemo, useState } from "react";
import "./App.css";

const FILTERS = ["all", "active", "completed"];
const STORAGE_KEY = "todos:v1";

function makeTodo(title) {
  return {
    id: crypto.randomUUID(),
    title: title.trim(),
    completed: false,
    createdAt: Date.now(),
  };
}

export default function App() {
  const [title, setTitle] = useState("");

  const [todos, setTodos] = useState(() => {
    const raw = localStorage.getItem(STORAGE_KEY);
    if (!raw) return [];
    try {
      const parsed = JSON.parse(raw);
      return Array.isArray(parsed) ? parsed : [];
    } catch {
      return [];
    }
  });

  const [filter, setFilter] = useState("all");

  useEffect(() => {
    localStorage.setItem(STORAGE_KEY, JSON.stringify(todos));
  }, [todos]);

  function handleSubmit(e) {
    e.preventDefault();
    const trimmed = title.trim();
    if (!trimmed) return;

    setTodos((prev) => [makeTodo(trimmed), ...prev]);
    setTitle("");
  }

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

  function deleteTodo(id) {
    setTodos((prev) => prev.filter((t) => t.id !== id));
  }

  function clearCompleted() {
    setTodos((prev) => prev.filter((t) => !t.completed));
  }

  const visibleTodos = useMemo(() => {
    if (filter === "active") return todos.filter((t) => !t.completed);
    if (filter === "completed") return todos.filter((t) => t.completed);
    return todos;
  }, [todos, filter]);

  const remainingCount = useMemo(
    () => todos.filter((t) => !t.completed).length,
    [todos]
  );

  const completedCount = todos.length - remainingCount;

  return (
    <main className="app">
      <h1>To-Do App</h1>

      <section className="card">
        <form className="row" onSubmit={handleSubmit}>
          <input
            value={title}
            onChange={(e) => setTitle(e.target.value)}
            placeholder="Add a task..."
            aria-label="Task title"
          />
          <button type="submit">Add</button>
        </form>

        <div className="filters" role="group" aria-label="Filters">
          {FILTERS.map((f) => (
            <button
              key={f}
              type="button"
              aria-pressed={filter === f}
              onClick={() => setFilter(f)}
            >
              {f[0].toUpperCase() + f.slice(1)}
            </button>
          ))}

          <button
            type="button"
            onClick={clearCompleted}
            disabled={completedCount === 0}
            title="Remove all completed tasks"
          >
            Clear completed
          </button>
        </div>

        <p className="muted">
          {todos.length === 0
            ? "No tasks yet. Add one above."
            : `${remainingCount} remaining, ${completedCount} completed`}
        </p>

        <ul className="todo-list">
          {visibleTodos.map((t) => (
            <li key={t.id} className="todo-item">
              <div className="todo-left">
                <input
                  type="checkbox"
                  checked={t.completed}
                  onChange={() => toggleTodo(t.id)}
                  aria-label={t.completed ? "Mark as active" : "Mark as completed"}
                />
                <span className={"todo-title" + (t.completed ? " completed" : "")}>
                  {t.title}
                </span>
              </div>

              <button type="button" onClick={() => deleteTodo(t.id)}>
                Delete
              </button>
            </li>
          ))}
        </ul>
      </section>
    </main>
  );
}

At this point you have a complete working to-do app.


Step 13: Test Your App (What to Try)

Try these actions:

  1. Add several tasks.
  2. Mark some completed.
  3. Switch filters:
    • All: shows everything
    • Active: shows only incomplete
    • Completed: shows only complete
  4. Delete a task.
  5. Click “Clear completed”.
  6. Refresh the page and verify tasks persist.

If something doesn’t work:


Step 14: Common Beginner Mistakes (And How to Avoid Them)

1) Mutating state directly

Bad (don’t do this):

todos.push(newTodo);
setTodos(todos);

Good:

setTodos((prev) => [newTodo, ...prev]);

React relies on reference changes to know something changed. Mutation keeps the same reference and can cause stale UI.

2) Using array index as a key

Bad:

{todos.map((t, i) => <li key={i}>{t.title}</li>)}

If you delete the first item, every index shifts, and React can mismatch DOM nodes. Use a stable id.

3) Storing derived data in state

Avoid:

Compute them from todos instead. This keeps your app consistent and easier to debug.


Step 15: Build for Production

When you’re ready to create a production build:

npm run build

To preview the production build locally:

npm run preview

Vite will serve the optimized build and show a local URL.


Ideas to Improve This App (Next Steps)

If you want to keep learning, here are realistic upgrades:

  1. Edit a task title
    • Add an “Edit” button
    • Swap the title for an input when editing
    • Save on Enter or blur
  2. Better validation
    • Prevent duplicate titles
    • Show an error message instead of silently ignoring empty input
  3. Sorting
    • Newest first (already)
    • Oldest first
    • Active tasks first
  4. Accessibility improvements
    • Add focus styles
    • Use more descriptive labels
  5. Component extraction
    • Create TodoItem.jsx and TodoFilters.jsx to practice splitting UI into components

Summary

You built a complete beginner-friendly React to-do app using core concepts:

If you want, tell me whether you’d like the next tutorial step to be editing tasks, drag-and-drop reordering, or moving to TypeScript—and I’ll extend this project accordingly.