React Project: Build a Todo App with Hooks
Put it together: build a working React todo app with state, events, lists, and forms, fully editable live in your browser.

You've spent thirteen lessons collecting pieces. State, events, a controlled input, a list rendered with keys, a bit of conditional rendering. On their own each one is a party trick. Bolted together, they're a real app. So let's build the app everyone builds, a todo list, because it touches every single one of those pieces and nothing else. Add a task, see it in the list, check it off, delete it, watch a counter track what's left. By the end you'll have a working thing you can run, edit, and break on purpose to see what happens.
What we're building
A todo app does four jobs, and you already know how to write every one of them:
- Add a todo by typing into a form and submitting it.
- Show the todos as a list, each with its own row.
- Toggle a todo between done and not done.
- Delete a todo you don't need anymore.
And a small fifth job to tie it off: show how many are still left to do. That's it. No backend, no database, no router, just React state holding an array, and four functions that change that array. Let's build it one job at a time and run after each.
Step 1: hold the todos in state
Everything starts with the data. A todo is just an object: an id so React can tell rows apart, a text for what to do, and a done flag. The whole list is an array of those objects, living in state.
const [todos, setTodos] = useState([
{ id: 1, text: "Learn useState", done: true },
{ id: 2, text: "Build a todo app", done: false },
]);Two sample todos so the screen isn't empty while we work. setTodos is the only way we'll ever change this array. Remember, you never mutate state in place, you hand the setter a brand-new array. That rule is about to matter in every step.
The id is the important bit. When we render this array as a list, React needs a stable, unique key per item to track which row is which. We'll talk about why in step 2, but it's why each todo carries its own id from birth.
Step 2: render the list with keys
Now show them. Take the array and turn it into JSX rows with .map(), exactly like in lists and keys:
<ul>
{todos.map((todo) => (
<li key={todo.id}>{todo.text}</li>
))}
</ul>todos.map(...) walks the array and returns one <li> per todo. The key={todo.id} is not optional decoration. It's how React matches each rendered row to its data across re-renders. Give it a stable id and React knows "this exact row stayed, that one got deleted." Skip the key (or use the array index) and React gets confused when the list reorders or shrinks, and you get checkboxes ticking the wrong rows. Stable id, stable key, no surprises.
Don't use the array index as the key
It's tempting to write key={index}. It works until you delete or reorder items, then React reuses the wrong DOM nodes and state attaches to the wrong row. Use a value that belongs to the item itself. Here, that's todo.id.
Step 3: add a todo with a controlled form
Time to type. We need an <input> whose value lives in state and a form that, on submit, pushes a new todo into the array. This is the controlled form pattern: the input shows state, and every keystroke flows back into state through onChange.
const [text, setText] = useState("");
function addTodo(e) {
e.preventDefault(); // stop the page reload
if (!text.trim()) return; // ignore empty/whitespace
const next = { id: crypto.randomUUID(), text: text.trim(), done: false };
setTodos([...todos, next]); // new array, old items + the new one
setText(""); // clear the input
}Three things earn their keep here. e.preventDefault() stops the browser's default form submit, which would reload the whole page and wipe your state. The text.trim() guard quietly ignores a submit when the box is empty or just spaces. And setTodos([...todos, next]) builds a new array. The spread copies the old todos, then tacks the new one on the end. We never todos.push(...), which mutates the array React is watching and the screen won't update.
For the id we use crypto.randomUUID(), built into every modern browser, which hands back a unique string every time. A new todo gets a fresh id nobody else has, which is exactly what a key needs.
Step 4: toggle done and delete
Two more functions, both following the same shape: produce a new array, pass it to the setter.
Toggling flips one todo's done flag while leaving the rest alone. We map over the array, and when we hit the matching id, we return a copy of that todo with done flipped, and everyone else passes through unchanged:
function toggle(id) {
setTodos(todos.map((t) =>
t.id === id ? { ...t, done: !t.done } : t
));
}Deleting is even simpler. Keep every todo whose id doesn't match, with .filter():
function remove(id) {
setTodos(todos.filter((t) => t.id !== id));
}Notice neither one touches the original array. map and filter both return new arrays, which is precisely why React notices the change and re-renders. This is the same handling-events wiring from handling events: a function runs in response to a click, and it changes state.
Step 5: the remaining count
One line of conditional logic, no extra state needed. The count of unfinished todos is just a filter over what we already have:
const remaining = todos.filter((t) => !t.done).length;Derive it, don't store it. If you kept remaining in its own useState, you'd have to remember to update it in every function that changes the list (add, toggle, delete), and the day you forget one, your counter lies. Computing it fresh on each render means it's always right, for free. That's a habit worth keeping: if a value can be calculated from state you already have, calculate it.
Quick check
Why compute `remaining` on each render instead of keeping it in its own useState?
The full app
Here's everything wired together. Type a task and hit Add, check the box to mark it done, hit Delete to remove it, and watch the counter at the top. Edit any of it and re-run.
Run it and the whole thing is alive. The form adds, the checkboxes toggle and strike through the text, Delete removes a row, the counter tracks what's left, and when you clear the list a friendly empty message takes over. That last bit, todos.length === 0 ? ... : ..., is conditional rendering doing real work: show the empty state or the list, never both.
Every line traces back to a lesson. State holds the array. The controlled input feeds the form. map builds the list with stable keys. Click handlers toggle and delete. Conditional rendering swaps the empty state. Nothing new, just the pieces, arranged to solve one specific problem.
Break it on purpose
Change key={todo.id} to key={Math.random()} and watch the input lose focus on every keystroke. That's React throwing away and rebuilding every row. Put the real key back. Seeing a thing break teaches faster than reading why it shouldn't.
Make it yours
You've got a working app, so now stretch it. A few additions, each using only what you already know:
- Clear completed: a button that runs
setTodos(todos.filter((t) => !t.done)). - Edit a todo: add an
editingflag and swap the text for an input when it's true. - Persist on reload: save
todostolocalStorageand read it back on load. That's a job for useEffect, the next tool past this series.
Don't add all three at once. Pick one, build it, run it, then reach for the next.
You finished the series
Look back at what you can do now. You started with why React exists, learned JSX, built components fed by props, gave them memory with state, wired up events, rendered lists with keys, handled conditional UI, and tamed forms. This todo app is all of it firing together, and you wrote every line.
For a slightly bigger React tutorial straight from the source, react.dev's tic-tac-toe walkthrough is a great next build. It reuses the exact same ideas on a different shape of problem.
Then there's the thing that quietly makes all of this sturdier as your apps grow. Right now a typo like todo.txt instead of todo.text fails silently at runtime, and remove("a1") versus remove(1) is a bug you only catch by clicking around. A type system catches both before you ever run the code. That's where we go next: TypeScript, the same JavaScript you know with a safety net that tells you when you're wrong while you type, not after you ship.

Written by
Rhythm Bhiwani
Engineer and relentless builder, happiest reverse-engineering hard problems until they click.
Enjoyed this?
Tap the heart to leave some love.
Be the first to react
Comments
Join the conversation.
Loading comments…


