Reading about props and state only gets you so far. This tutorial builds an actual, working project: a todo app with adding, completing and deleting items, where the list survives a page refresh. It is a small project, but it touches almost everything covered so far — state, lists, events and a side effect.
1. Project setup
Assuming a project created with Vite (npm create vite@latest todo-app -- --template react), open src/App.jsx and clear it out to start fresh.
2. Modelling the data
Each todo needs an id, some text, and a completed flag:
const initialTodos = [
{ id: 1, text: "Learn React basics", completed: true },
{ id: 2, text: "Build a real project", completed: false },
];3. State for the list and the input
import { useState, useEffect } from "react";
function App() {
const [todos, setTodos] = useState(initialTodos);
const [input, setInput] = useState("");
// ...
}4. Adding a todo
function addTodo() {
if (input.trim() === "") return;
const newTodo = { id: Date.now(), text: input, completed: false };
setTodos([...todos, newTodo]);
setInput("");
}Notice [...todos, newTodo] instead of todos.push(newTodo). React needs a brand new array reference to know something changed, so state is always updated by creating a new array or object, never by mutating the existing one in place. This is one of the most important habits to build early in React.
5. Toggling and deleting
function toggleTodo(id) {
setTodos(
todos.map((t) => (t.id === id ? { ...t, completed: !t.completed } : t))
);
}
function deleteTodo(id) {
setTodos(todos.filter((t) => t.id !== id));
}Same idea both times: build a new array with map() or filter() rather than mutating todos directly.
6. The full component
function App() {
const [todos, setTodos] = useState(initialTodos);
const [input, setInput] = useState("");
function addTodo() {
if (input.trim() === "") return;
setTodos([...todos, { id: Date.now(), text: input, completed: false }]);
setInput("");
}
return (
<div className="app">
<h1>My Todos</h1>
<input
value={input}
onChange={(e) => setInput(e.target.value)}
placeholder="What needs doing?"
/>
<button onClick={addTodo}>Add</button>
<ul>
{todos.map((todo) => (
<li key={todo.id} style={{ textDecoration: todo.completed ? "line-through" : "none" }}>
<span onClick={() => toggleTodo(todo.id)}>{todo.text}</span>
<button onClick={() => deleteTodo(todo.id)}>Delete</button>
</li>
))}
</ul>
</div>
);
}7. Making it survive a refresh with localStorage
Right now, refreshing the page loses every todo. Two small useEffect hooks fix that: one to load saved todos on startup, one to save them every time the list changes.
useEffect(() => {
const saved = localStorage.getItem("todos");
if (saved) setTodos(JSON.parse(saved));
}, []); // runs once, on mount
useEffect(() => {
localStorage.setItem("todos", JSON.stringify(todos));
}, [todos]); // runs every time todos changesWhat this project actually taught you
- Modelling a real, small dataset as state.
- Updating arrays immutably with spread,
map()andfilter(). - Rendering a dynamic list with proper keys.
- Two-way binding a text input to state.
- Using
useEffectfor something other than fetching — persistence.
This exact structure — a piece of state, a form to add to it, a list rendered from it — is the backbone of a huge share of real dashboards and admin tools. The next project builds on the same skills but adds a live external API into the mix.