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.