← Back to Tutorials

Building a To-Do App with React: State, Hooks, and CRUD

reactto-do appjavascripthooksstate managementcrudfrontend developmentweb development

Building a To-Do App with React: State, Hooks, and CRUD

This tutorial walks you through building a complete To‑Do application in React, focusing on state management, React Hooks, and CRUD operations (Create, Read, Update, Delete). You’ll start from a clean project, build the UI, implement behavior with hooks, persist data to localStorage, and structure your code in a maintainable way.

You’ll end with an app that can:


Table of Contents


Prerequisites

You should be comfortable with:

Installed tools:

Verify:

node -v
npm -v

1) Create a React project

You can use Vite for a fast dev environment.

npm create vite@latest react-todo -- --template react
cd react-todo
npm install
npm run dev

Vite will print a local dev URL (usually http://localhost:5173).


2) Project structure

We’ll use a simple structure:

react-todo/
  src/
    components/
      TodoForm.jsx
      TodoItem.jsx
      TodoList.jsx
      TodoFilters.jsx
    App.jsx
    main.jsx
    index.css

Create the components folder and files:

mkdir -p src/components
touch src/components/TodoForm.jsx src/components/TodoItem.jsx src/components/TodoList.jsx src/components/TodoFilters.jsx

3) Understanding state and re-renders

React components render UI based on state and props.

When state changes via a setter from useState, React schedules a re-render. A re-render means:

Why immutability matters

React state should be treated as immutable. Instead of changing an array/object in place, create a new one:

Immutability helps React detect changes and prevents subtle bugs.


4) Define the data model

A todo item needs enough information to support CRUD:

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

Notes:


5) Build the UI components (static first)

A reliable approach is to build the UI structure first, then wire up behavior.

App layout

Replace src/App.jsx with:

import { useEffect, useMemo, useState } from "react";
import TodoForm from "./components/TodoForm";
import TodoList from "./components/TodoList";
import TodoFilters from "./components/TodoFilters";
import "./index.css";

const STORAGE_KEY = "todos:v1";

export default function App() {
  const [todos, setTodos] = useState([]);
  const [filter, setFilter] = useState("all"); // "all" | "active" | "completed"

  return (
    <div className="container">
      <header className="header">
        <h1>To-Do App</h1>
        <p className="subtitle">React state, hooks, and CRUD in one small project.</p>
      </header>

      <main className="card">
        <TodoForm onAdd={() => {}} />

        <TodoFilters
          filter={filter}
          onChangeFilter={() => {}}
          total={todos.length}
          activeCount={0}
          completedCount={0}
        />

        <TodoList
          todos={todos}
          onToggle={() => {}}
          onDelete={() => {}}
          onEdit={() => {}}
        />
      </main>

      <footer className="footer">
        <small>Data will be saved in your browser (localStorage).</small>
      </footer>
    </div>
  );
}

At this stage, handlers are placeholders. The goal is to see the layout compile.

Add basic styling

Replace src/index.css with:

:root {
  font-family: system-ui, -apple-system, Segoe UI, Roboto, Arial, sans-serif;
  color: #111827;
  background: #f3f4f6;
}

* { box-sizing: border-box; }

body {
  margin: 0;
}

.container {
  max-width: 760px;
  margin: 40px auto;
  padding: 0 16px;
}

.header {
  margin-bottom: 16px;
}

.subtitle {
  margin-top: 4px;
  color: #4b5563;
}

.card {
  background: white;
  border: 1px solid #e5e7eb;
  border-radius: 12px;
  padding: 16px;
  box-shadow: 0 8px 24px rgba(0,0,0,0.06);
}

.footer {
  margin-top: 16px;
  color: #6b7280;
}

.row {
  display: flex;
  gap: 8px;
  align-items: center;
}

input[type="text"] {
  width: 100%;
  padding: 10px 12px;
  border: 1px solid #d1d5db;
  border-radius: 10px;
  font-size: 14px;
}

button {
  padding: 10px 12px;
  border: 1px solid #d1d5db;
  border-radius: 10px;
  background: #111827;
  color: white;
  cursor: pointer;
}

button.secondary {
  background: white;
  color: #111827;
}

button:disabled {
  opacity: 0.5;
  cursor: not-allowed;
}

.list {
  margin-top: 12px;
  padding: 0;
  list-style: none;
}

.item {
  display: flex;
  gap: 10px;
  align-items: center;
  padding: 10px 8px;
  border-bottom: 1px solid #f3f4f6;
}

.item:last-child {
  border-bottom: none;
}

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

.title.completed {
  text-decoration: line-through;
  color: #6b7280;
}

.muted {
  color: #6b7280;
}

.pillbar {
  display: flex;
  gap: 8px;
  flex-wrap: wrap;
  margin-top: 12px;
  align-items: center;
  justify-content: space-between;
}

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

.pill {
  padding: 6px 10px;
  border-radius: 999px;
  border: 1px solid #d1d5db;
  background: white;
  cursor: pointer;
}

.pill.active {
  background: #111827;
  color: white;
  border-color: #111827;
}

TodoForm

Create src/components/TodoForm.jsx:

import { useState } from "react";

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

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

  return (
    <form className="row" onSubmit={handleSubmit}>
      <input
        type="text"
        value={title}
        placeholder="Add a task (e.g., 'Read 10 pages')"
        onChange={(e) => setTitle(e.target.value)}
        aria-label="Todo title"
      />
      <button type="submit" disabled={!title.trim()}>
        Add
      </button>
    </form>
  );
}

Key idea: controlled input

The input’s value is controlled by React state:

TodoList and TodoItem

Create src/components/TodoList.jsx:

import TodoItem from "./TodoItem";

export default function TodoList({ todos, onToggle, onDelete, onEdit }) {
  if (todos.length === 0) {
    return <p className="muted" style={{ marginTop: 12 }}>No tasks yet. Add one above.</p>;
  }

  return (
    <ul className="list">
      {todos.map((t) => (
        <TodoItem
          key={t.id}
          todo={t}
          onToggle={onToggle}
          onDelete={onDelete}
          onEdit={onEdit}
        />
      ))}
    </ul>
  );
}

Create src/components/TodoItem.jsx:

import { useState } from "react";

export default function TodoItem({ todo, onToggle, onDelete, onEdit }) {
  const [isEditing, setIsEditing] = useState(false);
  const [draft, setDraft] = useState(todo.title);

  function startEdit() {
    setDraft(todo.title);
    setIsEditing(true);
  }

  function cancelEdit() {
    setDraft(todo.title);
    setIsEditing(false);
  }

  function saveEdit() {
    const trimmed = draft.trim();
    if (!trimmed) return;
    onEdit(todo.id, trimmed);
    setIsEditing(false);
  }

  return (
    <li className="item">
      <input
        type="checkbox"
        checked={todo.completed}
        onChange={() => onToggle(todo.id)}
        aria-label={`Mark "${todo.title}" as completed`}
      />

      {!isEditing ? (
        <span className={`title ${todo.completed ? "completed" : ""}`}>
          {todo.title}
        </span>
      ) : (
        <input
          type="text"
          value={draft}
          onChange={(e) => setDraft(e.target.value)}
          aria-label="Edit todo title"
        />
      )}

      {!isEditing ? (
        <>
          <button className="secondary" onClick={startEdit}>
            Edit
          </button>
          <button className="secondary" onClick={() => onDelete(todo.id)}>
            Delete
          </button>
        </>
      ) : (
        <>
          <button onClick={saveEdit} disabled={!draft.trim()}>
            Save
          </button>
          <button className="secondary" onClick={cancelEdit}>
            Cancel
          </button>
        </>
      )}
    </li>
  );
}

Why local state inside TodoItem?

Editing is a temporary UI mode. The “draft” text is not committed until the user clicks Save. This is a great use case for component-local state:

Filters and counters

Create src/components/TodoFilters.jsx:

export default function TodoFilters({
  filter,
  onChangeFilter,
  total,
  activeCount,
  completedCount,
}) {
  return (
    <div className="pillbar">
      <div className="pills" role="tablist" aria-label="Todo filters">
        <button
          className={`pill ${filter === "all" ? "active" : ""}`}
          onClick={() => onChangeFilter("all")}
          type="button"
        >
          All ({total})
        </button>
        <button
          className={`pill ${filter === "active" ? "active" : ""}`}
          onClick={() => onChangeFilter("active")}
          type="button"
        >
          Active ({activeCount})
        </button>
        <button
          className={`pill ${filter === "completed" ? "active" : ""}`}
          onClick={() => onChangeFilter("completed")}
          type="button"
        >
          Completed ({completedCount})
        </button>
      </div>

      <div className="muted">
        Tip: Double-check tasks you’ve finished.
      </div>
    </div>
  );
}

6) Implement CRUD with hooks

Now we connect the handlers in App.jsx and implement CRUD operations.

Create (add a todo)

In App.jsx, implement onAdd:

Read (render todos)

“Read” is mostly just rendering state. But we’ll also compute a filtered view of todos.

Update (toggle and edit)

Delete (remove a todo)

Remove a todo by filtering it out.

Replace src/App.jsx with the following complete version (we’ll add persistence in the next section, but this includes placeholders for it):

import { useEffect, useMemo, useState } from "react";
import TodoForm from "./components/TodoForm";
import TodoList from "./components/TodoList";
import TodoFilters from "./components/TodoFilters";
import "./index.css";

const STORAGE_KEY = "todos:v1";

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

export default function App() {
  const [todos, setTodos] = useState([]);
  const [filter, setFilter] = useState("all"); // "all" | "active" | "completed"

  function handleAdd(title) {
    const newTodo = createTodo(title);
    setTodos((prev) => [newTodo, ...prev]);
  }

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

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

  function handleEdit(id, nextTitle) {
    setTodos((prev) =>
      prev.map((t) => (t.id === id ? { ...t, title: nextTitle } : t))
    );
  }

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

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

  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]);

  // Persistence will be added later; keep useEffect imports for now.
  useEffect(() => {
    // no-op for now
  }, []);

  return (
    <div className="container">
      <header className="header">
        <h1>To-Do App</h1>
        <p className="subtitle">React state, hooks, and CRUD in one small project.</p>
      </header>

      <main className="card">
        <TodoForm onAdd={handleAdd} />

        <TodoFilters
          filter={filter}
          onChangeFilter={setFilter}
          total={todos.length}
          activeCount={activeCount}
          completedCount={completedCount}
        />

        <TodoList
          todos={visibleTodos}
          onToggle={handleToggle}
          onDelete={handleDelete}
          onEdit={handleEdit}
        />
      </main>

      <footer className="footer">
        <small>Data will be saved in your browser (localStorage).</small>
      </footer>
    </div>
  );
}

What makes this “CRUD”?

Even though this is a frontend-only app, the same conceptual operations exist in full-stack apps. The difference is: instead of calling an API, we update local state (and later persist to localStorage).


7) Derived state: filtering and counts

A common React question is: “Should I store counts and filtered arrays in state?”

Usually: no. If something can be derived from existing state, compute it during render (or memoize it if needed).

Why derived state is better

If you store activeCount in state, you must update it every time todos changes. That creates more places for bugs:

Instead, compute it from todos:

const activeCount = todos.filter(t => !t.completed).length;

Why useMemo?

useMemo caches a computed value until dependencies change. In this app, todos is small, so useMemo is optional. But it’s useful to learn:

Important: useMemo is not a guarantee of performance improvements; it’s a tool. Use it when computations are non-trivial or when referential equality matters.


8) Persist to localStorage with useEffect

Right now, refreshing the page will reset todos. Let’s save them in the browser.

We’ll implement two effects:

  1. Load todos once on mount.
  2. Save todos whenever they change.

Loading from localStorage

localStorage stores strings, so we use JSON.parse.

Add this helper in App.jsx (near the top):

function loadTodos() {
  try {
    const raw = localStorage.getItem(STORAGE_KEY);
    if (!raw) return [];
    const parsed = JSON.parse(raw);
    if (!Array.isArray(parsed)) return [];
    return parsed;
  } catch {
    return [];
  }
}

Saving to localStorage

Use JSON.stringify and store after every change.

Now replace the no-op effect with two real effects. Update App.jsx like this:

import { useEffect, useMemo, useState } from "react";
import TodoForm from "./components/TodoForm";
import TodoList from "./components/TodoList";
import TodoFilters from "./components/TodoFilters";
import "./index.css";

const STORAGE_KEY = "todos:v1";

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

function loadTodos() {
  try {
    const raw = localStorage.getItem(STORAGE_KEY);
    if (!raw) return [];
    const parsed = JSON.parse(raw);
    if (!Array.isArray(parsed)) return [];
    return parsed;
  } catch {
    return [];
  }
}

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

  // 1) Load once on mount
  useEffect(() => {
    setTodos(loadTodos());
  }, []);

  // 2) Save whenever todos change
  useEffect(() => {
    localStorage.setItem(STORAGE_KEY, JSON.stringify(todos));
  }, [todos]);

  function handleAdd(title) {
    const newTodo = createTodo(title);
    setTodos((prev) => [newTodo, ...prev]);
  }

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

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

  function handleEdit(id, nextTitle) {
    setTodos((prev) =>
      prev.map((t) => (t.id === id ? { ...t, title: nextTitle } : t))
    );
  }

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

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

  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]);

  return (
    <div className="container">
      <header className="header">
        <h1>To-Do App</h1>
        <p className="subtitle">React state, hooks, and CRUD in one small project.</p>
      </header>

      <main className="card">
        <TodoForm onAdd={handleAdd} />

        <TodoFilters
          filter={filter}
          onChangeFilter={setFilter}
          total={todos.length}
          activeCount={activeCount}
          completedCount={completedCount}
        />

        <TodoList
          todos={visibleTodos}
          onToggle={handleToggle}
          onDelete={handleDelete}
          onEdit={handleEdit}
        />
      </main>

      <footer className="footer">
        <small>Saved locally in your browser (localStorage).</small>
      </footer>
    </div>
  );
}

Why two effects?

This separation keeps responsibilities clear and avoids tricky edge cases.

A subtle detail: initial state and saving

If you start with useState([]) and load later in an effect, the “save” effect will run after the first render too. In practice, React runs effects after paint, and the load effect will set todos, then the save effect will run with the updated value. This is usually fine.

If you want to avoid any chance of writing [] before loading, you can initialize state from loadTodos() directly:

const [todos, setTodos] = useState(() => loadTodos());

Then you can remove the “load once” effect entirely. That approach is often cleaner. However, it can be slightly less friendly in environments where localStorage is not available (SSR). Since this is a browser-only Vite app, it’s safe.


9) Common pitfalls (and how to avoid them)

1) Mutating state directly

Bad:

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

This mutates the existing array. React may not detect changes properly, and you can create bugs where UI doesn’t update as expected.

Good:

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

2) Using array index as key

Bad:

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

If you insert/remove items, indices shift, and React may reuse the wrong component instance. This is especially problematic with local state (like isEditing and draft).

Good:

{todos.map(t => <TodoItem key={t.id} ... />)}

3) Storing derived values in state

Avoid:

Instead, derive them from todos and filter.

4) Forgetting to trim input

Without trimming, users can add blank tasks that are just spaces. We used:

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

5) Editing UX: keeping draft in sync

When you click “Edit”, you want the input to reflect the latest title. That’s why startEdit resets draft from todo.title.


10) Optional improvements

These are good next steps if you want to extend the app.

Add “Clear completed”

In App.jsx:

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

Add a button in the UI (for example in the footer or near filters).

Add sorting

If you want newest first (we already insert at the top), or sort by completion:

const visibleTodos = useMemo(() => {
  const filtered =
    filter === "active" ? todos.filter(t => !t.completed)
    : filter === "completed" ? todos.filter(t => t.completed)
    : todos;

  return [...filtered].sort((a, b) => b.createdAt - a.createdAt);
}, [todos, filter]);

Note the [...] copy before sorting—sort() mutates arrays.

Add keyboard support for editing

In TodoItem.jsx, save on Enter and cancel on Escape:

onKeyDown={(e) => {
  if (e.key === "Enter") saveEdit();
  if (e.key === "Escape") cancelEdit();
}}

Replace localStorage with a real API

CRUD becomes HTTP calls:

You’d still keep React state, but you’d synchronize it with server responses and handle loading/error states.


Complete source code

This section repeats the final versions for easy copy/paste.

src/App.jsx

import { useEffect, useMemo, useState } from "react";
import TodoForm from "./components/TodoForm";
import TodoList from "./components/TodoList";
import TodoFilters from "./components/TodoFilters";
import "./index.css";

const STORAGE_KEY = "todos:v1";

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

function loadTodos() {
  try {
    const raw = localStorage.getItem(STORAGE_KEY);
    if (!raw) return [];
    const parsed = JSON.parse(raw);
    if (!Array.isArray(parsed)) return [];
    return parsed;
  } catch {
    return [];
  }
}

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

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

  function handleAdd(title) {
    const newTodo = createTodo(title);
    setTodos((prev) => [newTodo, ...prev]);
  }

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

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

  function handleEdit(id, nextTitle) {
    setTodos((prev) =>
      prev.map((t) => (t.id === id ? { ...t, title: nextTitle } : t))
    );
  }

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

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

  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]);

  return (
    <div className="container">
      <header className="header">
        <h1>To-Do App</h1>
        <p className="subtitle">React state, hooks, and CRUD in one small project.</p>
      </header>

      <main className="card">
        <TodoForm onAdd={handleAdd} />

        <TodoFilters
          filter={filter}
          onChangeFilter={setFilter}
          total={todos.length}
          activeCount={activeCount}
          completedCount={completedCount}
        />

        <TodoList
          todos={visibleTodos}
          onToggle={handleToggle}
          onDelete={handleDelete}
          onEdit={handleEdit}
        />
      </main>

      <footer className="footer">
        <small>Saved locally in your browser (localStorage).</small>
      </footer>
    </div>
  );
}

src/components/TodoForm.jsx

import { useState } from "react";

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

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

  return (
    <form className="row" onSubmit={handleSubmit}>
      <input
        type="text"
        value={title}
        placeholder="Add a task (e.g., 'Read 10 pages')"
        onChange={(e) => setTitle(e.target.value)}
        aria-label="Todo title"
      />
      <button type="submit" disabled={!title.trim()}>
        Add
      </button>
    </form>
  );
}

src/components/TodoList.jsx

import TodoItem from "./TodoItem";

export default function TodoList({ todos, onToggle, onDelete, onEdit }) {
  if (todos.length === 0) {
    return <p className="muted" style={{ marginTop: 12 }}>No tasks yet. Add one above.</p>;
  }

  return (
    <ul className="list">
      {todos.map((t) => (
        <TodoItem
          key={t.id}
          todo={t}
          onToggle={onToggle}
          onDelete={onDelete}
          onEdit={onEdit}
        />
      ))}
    </ul>
  );
}

src/components/TodoItem.jsx

import { useState } from "react";

export default function TodoItem({ todo, onToggle, onDelete, onEdit }) {
  const [isEditing, setIsEditing] = useState(false);
  const [draft, setDraft] = useState(todo.title);

  function startEdit() {
    setDraft(todo.title);
    setIsEditing(true);
  }

  function cancelEdit() {
    setDraft(todo.title);
    setIsEditing(false);
  }

  function saveEdit() {
    const trimmed = draft.trim();
    if (!trimmed) return;
    onEdit(todo.id, trimmed);
    setIsEditing(false);
  }

  return (
    <li className="item">
      <input
        type="checkbox"
        checked={todo.completed}
        onChange={() => onToggle(todo.id)}
        aria-label={`Mark "${todo.title}" as completed`}
      />

      {!isEditing ? (
        <span className={`title ${todo.completed ? "completed" : ""}`}>
          {todo.title}
        </span>
      ) : (
        <input
          type="text"
          value={draft}
          onChange={(e) => setDraft(e.target.value)}
          aria-label="Edit todo title"
        />
      )}

      {!isEditing ? (
        <>
          <button className="secondary" onClick={startEdit} type="button">
            Edit
          </button>
          <button
            className="secondary"
            onClick={() => onDelete(todo.id)}
            type="button"
          >
            Delete
          </button>
        </>
      ) : (
        <>
          <button onClick={saveEdit} disabled={!draft.trim()} type="button">
            Save
          </button>
          <button className="secondary" onClick={cancelEdit} type="button">
            Cancel
          </button>
        </>
      )}
    </li>
  );
}

src/components/TodoFilters.jsx

export default function TodoFilters({
  filter,
  onChangeFilter,
  total,
  activeCount,
  completedCount,
}) {
  return (
    <div className="pillbar">
      <div className="pills" role="tablist" aria-label="Todo filters">
        <button
          className={`pill ${filter === "all" ? "active" : ""}`}
          onClick={() => onChangeFilter("all")}
          type="button"
        >
          All ({total})
        </button>
        <button
          className={`pill ${filter === "active" ? "active" : ""}`}
          onClick={() => onChangeFilter("active")}
          type="button"
        >
          Active ({activeCount})
        </button>
        <button
          className={`pill ${filter === "completed" ? "active" : ""}`}
          onClick={() => onChangeFilter("completed")}
          type="button"
        >
          Completed ({completedCount})
        </button>
      </div>

      <div className="muted">Tip: Keep tasks small and actionable.</div>
    </div>
  );
}

src/index.css

:root {
  font-family: system-ui, -apple-system, Segoe UI, Roboto, Arial, sans-serif;
  color: #111827;
  background: #f3f4f6;
}

* { box-sizing: border-box; }

body {
  margin: 0;
}

.container {
  max-width: 760px;
  margin: 40px auto;
  padding: 0 16px;
}

.header {
  margin-bottom: 16px;
}

.subtitle {
  margin-top: 4px;
  color: #4b5563;
}

.card {
  background: white;
  border: 1px solid #e5e7eb;
  border-radius: 12px;
  padding: 16px;
  box-shadow: 0 8px 24px rgba(0,0,0,0.06);
}

.footer {
  margin-top: 16px;
  color: #6b7280;
}

.row {
  display: flex;
  gap: 8px;
  align-items: center;
}

input[type="text"] {
  width: 100%;
  padding: 10px 12px;
  border: 1px solid #d1d5db;
  border-radius: 10px;
  font-size: 14px;
}

button {
  padding: 10px 12px;
  border: 1px solid #d1d5db;
  border-radius: 10px;
  background: #111827;
  color: white;
  cursor: pointer;
}

button.secondary {
  background: white;
  color: #111827;
}

button:disabled {
  opacity: 0.5;
  cursor: not-allowed;
}

.list {
  margin-top: 12px;
  padding: 0;
  list-style: none;
}

.item {
  display: flex;
  gap: 10px;
  align-items: center;
  padding: 10px 8px;
  border-bottom: 1px solid #f3f4f6;
}

.item:last-child {
  border-bottom: none;
}

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

.title.completed {
  text-decoration: line-through;
  color: #6b7280;
}

.muted {
  color: #6b7280;
}

.pillbar {
  display: flex;
  gap: 8px;
  flex-wrap: wrap;
  margin-top: 12px;
  align-items: center;
  justify-content: space-between;
}

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

.pill {
  padding: 6px 10px;
  border-radius: 999px;
  border: 1px solid #d1d5db;
  background: white;
  cursor: pointer;
}

.pill.active {
  background: #111827;
  color: white;
  border-color: #111827;
}

Running the app

From the project root:

npm run dev

Build for production:

npm run build
npm run preview

At this point you have a functional To‑Do app demonstrating React state, hooks (useState, useEffect, useMemo), component composition, and full CRUD behavior with persistence.