Home / React / Lesson 11

Project: Todo app with localStorage

Intermediate 12 min read Lesson 11 of 11

Build this in App.jsx of your Vite app. It uses only React and the browser. Todos survive refresh because they are stored under aup-react-todos on your machine — not on our server.

import { useEffect, useState } from 'react';

const KEY = 'aup-react-todos';

export default function App() {
  const [text, setText] = useState('');
  const [todos, setTodos] = useState(() => {
    try { return JSON.parse(localStorage.getItem(KEY)) || []; }
    catch { return []; }
  });

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

  function add(e) {
    e.preventDefault();
    const t = text.trim();
    if (!t) return;
    setTodos([{ id: crypto.randomUUID(), t, done: false }, ...todos]);
    setText('');
  }

  return (
    <main style={{ maxWidth: 420, margin: '40px auto', fontFamily: 'sans-serif' }}>
      <h1>Todos</h1>
      <form onSubmit={add}>
        <input value={text} onChange={(e) => setText(e.target.value)} placeholder="Add a task" />
        <button type="submit">Add</button>
      </form>
      <ul>
        {todos.map((item) => (
          <li key={item.id}>
            <label>
              <input
                type="checkbox"
                checked={item.done}
                onChange={() => setTodos(todos.map((x) => x.id === item.id ? { ...x, done: !x.done } : x))}
              />
              {item.t}
            </label>
            <button type="button" onClick={() => setTodos(todos.filter((x) => x.id !== item.id))}>Delete</button>
          </li>
        ))}
      </ul>
    </main>
  );
}

crypto.randomUUID() exists in current Chrome, Edge, Firefox and Safari. The lazy useState(() => ...) initializer runs once so we do not parse JSON on every render.

When this feels easy, add a filter (all / open / done) with a piece of state — same pattern as the vanilla JS todo, with React owning the list.