What You Are Building
A task list is the traditional final project for a good reason, and it is not that it is easy. It happens to touch nearly every idea in this course: a list rendered with keys, a controlled form, immutable updates to an array of objects, derived counts, state shared between components, persistence to the browser, and filters that live in the URL. Build it properly and you have used everything.
Build it properly is the operative phrase. The version you can copy from anywhere handles the happy path. The version worth putting in a portfolio handles an empty list, refuses to add a blank task, keeps your data after a refresh, lets you edit a task without losing it if you change your mind, and produces a shareable link when you filter. That is the version this lesson walks through.
Work in the order given below and keep the app running the whole time. Each step should end with something you can click. Building all the components first and wiring them at the end is how projects stall, because the first thing you test is also the first thing that fails, and you have no idea which of the six new files is responsible.
- Add a task, with blank and whitespace-only input rejected
- Tick a task complete, and untick it
- Edit a task's text in place, with Cancel restoring the original
- Delete a task
- Filter by All, Active and Completed, with the filter visible in the URL
- A live count of what is left, plus a Clear completed action
- Everything still there after a refresh
- Sensible empty states: no tasks at all, versus none matching the filter
- Start with
npm create vite@latest todo-app -- --template react, delete the placeholder content inApp.jsxandApp.css, and check that the empty page loads before writing anything.
Design the State Before the Components
Fifteen minutes deciding what your state looks like will save you two hours of refactoring. Ask the four questions from the previous lesson about every value the app deals with.
The tasks themselves are clearly state: an array of objects, each with a stable id generated when the task is created, the text, and a done flag. A createdAt timestamp costs nothing and lets you sort later. The id must be created in the add function, never in the render — a fresh id on every render would give React a new key every time and force it to rebuild the whole list.
The current filter is state too, but it belongs in the URL rather than in a useState. Putting it in a query parameter means the back button steps through filters, and a filtered view can be bookmarked or sent to someone. That is a small decision that makes the app feel like a real one.
Everything else is derived and must not be stored. The visible list is the tasks filtered by the current filter. The remaining count is the number of tasks that are not done. Whether the Clear completed button should appear is whether any task is done. Store any of those and you have created a second source of truth that will eventually disagree with the first.
// One task
{
id: 'c2f8…', // created once, when the task is added
text: 'Submit DBMS assignment',
done: false,
createdAt: 1738400000000
}
// STATE (the only things actually stored)
// todos — an array of the objects above
// filter — 'all' | 'active' | 'completed', held in the URL
// editingId, draftText — which task is being edited, in the item component
// DERIVED (calculated during render, never stored)
const visible =
filter === 'active' ? todos.filter(t => !t.done)
: filter === 'completed' ? todos.filter(t => t.done)
: todos;
const remaining = todos.filter(t => !t.done).length;
const hasCompleted = todos.some(t => t.done); crypto.randomUUID()works in modern browsers onhttps://and onlocalhost. If you deploy somewhere it is unavailable, swap in a small counter or theuuidpackage — the rest of the app does not care what the ids look like, only that they are unique and stable.
Step 1: State, Persistence and the Provider
Start at the centre. Write the custom hook that keeps a value in localStorage, then the context that owns the task list and the four operations on it. Everything else in the app will be a component reading from this.
useLocalStorage is the hook from the custom hooks lesson: lazy initial state so the read and parse happen once, and an effect that writes back whenever the value changes. Wrap the parse in a try — a user with corrupted storage should get an empty list, not a white screen.
The provider holds the tasks and exposes addTodo, toggleTodo, updateTodo and deleteTodo. Every one of them replaces the array rather than mutating it: spread to add, map to change one, filter to remove. Each uses the updater form, so a fast sequence of clicks cannot lose an update. And addTodo trims the text and refuses an empty string, because validation belongs with the operation rather than in whichever component happens to call it.
Finish the step by exporting a useTodos hook that throws a clear error when the provider is missing. Then wrap <App /> in the provider and confirm the app still renders. Nothing visible has changed yet, which is exactly right.
// src/hooks/useLocalStorage.js
import { useState, useEffect } from 'react';
export function useLocalStorage(key, initialValue) {
const [value, setValue] = useState(() => {
try {
const saved = localStorage.getItem(key);
return saved !== null ? JSON.parse(saved) : initialValue;
} catch {
return initialValue;
}
});
useEffect(() => {
localStorage.setItem(key, JSON.stringify(value));
}, [key, value]);
return [value, setValue];
}
// src/context/TodoContext.jsx
import { createContext, useContext } from 'react';
import { useLocalStorage } from '../hooks/useLocalStorage';
const TodoContext = createContext(null);
export function TodoProvider({ children }) {
const [todos, setTodos] = useLocalStorage('todos', []);
function addTodo(text) {
const clean = text.trim();
if (!clean) return;
setTodos(prev => [
{ id: crypto.randomUUID(), text: clean, done: false, createdAt: Date.now() },
...prev
]);
}
function toggleTodo(id) {
setTodos(prev => prev.map(t => (t.id === id ? { ...t, done: !t.done } : t)));
}
function updateTodo(id, text) {
const clean = text.trim();
if (!clean) return;
setTodos(prev => prev.map(t => (t.id === id ? { ...t, text: clean } : t)));
}
function deleteTodo(id) {
setTodos(prev => prev.filter(t => t.id !== id));
}
function clearCompleted() {
setTodos(prev => prev.filter(t => !t.done));
}
const value = { todos, addTodo, toggleTodo, updateTodo, deleteTodo, clearCompleted };
return <TodoContext.Provider value={value}>{children}</TodoContext.Provider>;
}
export function useTodos() {
const context = useContext(TodoContext);
if (context === null) throw new Error('useTodos must be used inside <TodoProvider>');
return context;
} - Test persistence now rather than later. Add a task from the browser console with
localStorage.setItem('todos', '[]'), refresh, and confirm nothing crashes. Storage bugs found now are trivial; found after the UI exists, they are confusing.
Step 2: The Form and the List
Now the visible part. AddTodo is a controlled input with local state for the text, submitting through the form's onSubmit so that the Enter key works. Call preventDefault, hand the text to addTodo, and clear the field. Disabling the button on empty input tells the user what is happening better than silently ignoring the click.
TodoList reads the tasks and the filter, computes the visible list during render, and maps it to TodoItem components keyed by todo.id. Use the id, not the index — tasks are added at the top and removed from the middle, which is precisely the case where index keys attach the wrong checkbox state to the wrong row.
TodoItem holds one genuinely local piece of state: whether it is currently being edited, and the draft text while it is. That state belongs in the item, not in the provider, because no other component needs it. Editing shows an input; Save calls updateTodo; Cancel discards the draft and leaves the original untouched.
Handle the two empty states differently. No tasks at all deserves an encouraging prompt; tasks exist but none match the filter deserves a message saying so. A user who cannot tell those apart assumes the app has lost their data.
// src/components/AddTodo.jsx
function AddTodo() {
const [text, setText] = useState('');
const { addTodo } = useTodos();
function handleSubmit(e) {
e.preventDefault();
addTodo(text);
setText('');
}
return (
<form onSubmit={handleSubmit}>
<input
value={text}
onChange={e => setText(e.target.value)}
placeholder="What needs doing?"
aria-label="New task"
/>
<button type="submit" disabled={text.trim() === ''}>Add</button>
</form>
);
}
// src/components/TodoItem.jsx
function TodoItem({ todo }) {
const { toggleTodo, updateTodo, deleteTodo } = useTodos();
const [isEditing, setIsEditing] = useState(false);
const [draft, setDraft] = useState(todo.text);
function save() {
updateTodo(todo.id, draft);
setIsEditing(false);
}
function cancel() {
setDraft(todo.text); // throw the draft away
setIsEditing(false);
}
if (isEditing) {
return (
<li>
<input
value={draft}
onChange={e => setDraft(e.target.value)}
onKeyDown={e => {
if (e.key === 'Enter') save();
if (e.key === 'Escape') cancel();
}}
autoFocus
/>
<button onClick={save}>Save</button>
<button onClick={cancel}>Cancel</button>
</li>
);
}
return (
<li className={todo.done ? 'done' : ''}>
<input
type="checkbox"
checked={todo.done}
onChange={() => toggleTodo(todo.id)}
aria-label={`Mark ${todo.text} as done`}
/>
<span onDoubleClick={() => setIsEditing(true)}>{todo.text}</span>
<button onClick={() => setIsEditing(true)}>Edit</button>
<button onClick={() => deleteTodo(todo.id)}>Delete</button>
</li>
);
} TodoItemreads its actions from context rather than receiving them as props. In an app this size either approach is fine; the context version keepsTodoListfree of handlers it does not use itself.
Step 3: Filters in the URL, and the Footer
The filter could be a useState in App, and plenty of tutorials stop there. Putting it in the URL instead costs three extra lines and gives you a working back button and shareable links — /?filter=active shows exactly what the sender was looking at.
Install React Router, wrap the app in BrowserRouter, and read the value with useSearchParams. It behaves like useState, except the value lives in the address bar. Default to all when the parameter is missing, so a bare URL still works.
The footer is pure derived data: how many tasks remain, the three filter links, and a Clear completed button that only appears when there is something to clear. Nothing here is stored — every value is calculated from todos during render, which is why the counts can never drift out of step with the list.
// src/App.jsx
import { useSearchParams } from 'react-router-dom';
import { useTodos } from './context/TodoContext';
function App() {
const { todos, clearCompleted } = useTodos();
const [searchParams, setSearchParams] = useSearchParams();
const filter = searchParams.get('filter') ?? 'all';
const visible =
filter === 'active' ? todos.filter(t => !t.done)
: filter === 'completed' ? todos.filter(t => t.done)
: todos;
const remaining = todos.filter(t => !t.done).length;
const hasCompleted = todos.some(t => t.done);
return (
<main className="app">
<h1>My tasks</h1>
<AddTodo />
{todos.length === 0 ? (
<p>Nothing here yet. Add your first task above.</p>
) : visible.length === 0 ? (
<p>No {filter} tasks.</p>
) : (
<ul>
{visible.map(todo => (
<TodoItem key={todo.id} todo={todo} />
))}
</ul>
)}
{todos.length > 0 && (
<footer>
<span>{remaining} left</span>
{['all', 'active', 'completed'].map(name => (
<button
key={name}
className={filter === name ? 'active' : ''}
onClick={() => setSearchParams(name === 'all' ? {} : { filter: name })}
>
{name}
</button>
))}
{hasCompleted && (
<button onClick={clearCompleted}>Clear completed</button>
)}
</footer>
)}
</main>
);
} - If you deploy this and a refresh on
/?filter=activereturns a 404, that is the server fallback problem from the routing lesson — the host needs a rule sending unmatched paths toindex.html.
Finish It, Then Break It
Before calling it done, try to break it deliberately. The list below is what a reviewer will do in their first thirty seconds, and every item on it maps back to something in this course — which makes it a decent self-test of whether the ideas have landed.
Then make it yours. A todo app that looks like every other todo app is worth very little in a portfolio; the same code with a purpose behind it is worth a great deal. Turn it into a semester assignment tracker with due dates and subject tags, an expense log with a monthly total, a reading list with progress, a workout log, or a shared shopping list. The state design barely changes; the framing changes everything about how it reads to someone else.
As for what comes next: the honest answer is another project, slightly harder, with a real API in it. Fetching data from a public API, handling the loading and error states properly, and deploying the result will teach you more than any further reading. After that, TypeScript and then Next.js are the natural steps, and both will feel manageable because the React underneath them is the React you already know.
- Add a task, refresh the page — is it still there?
- Try to add an empty task, or one that is only spaces
- Tick a task, then add a new one at the top — did the tick stay on the right task?
- Start editing, type something, press Escape — is the original text intact?
- Filter to Completed, press the back button — does it return to the previous filter?
- Delete every task — is there a sensible empty state?
- Open the console — any errors or warnings, including missing keys?
- Run
npm run buildand thennpm run preview— does the built version work?
- That is the end of the course. You now know function components, props, state, events, lists and keys, forms, effects, refs, custom hooks, context, routing, data fetching, performance and styling — which is the whole working vocabulary of modern React. What remains is repetition on real problems, and the documentation at react.dev when you need it.
