Build a typed Todo app in React + TypeScript: model data, render lists, and keep state updates safe.
What you will learn
- Scaffold a Vite + React + TypeScript app
- Define a
Todotype and keep updates immutable - Add, toggle, and filter todos
- Separate presentational components from state
Prerequisites
- Node.js 20+
- Basic React and TypeScript
Scaffold
npm create vite@latest todo-ts -- --template react-ts
cd todo-ts
npm install
npm run devVite guide: Vite — Getting Started. React TypeScript tips: React + TypeScript cheatsheet.
Model the data
export type Todo = {
id: string;
text: string;
completed: boolean;
};Minimal app state
import { useMemo, useState } from "react";
import type { Todo } from "./types";
export default function App() {
const [todos, setTodos] = useState<Todo[]>([]);
const [text, setText] = useState("");
const remaining = useMemo(() => todos.filter((todo) => !todo.completed).length, [todos]);
function addTodo() {
const value = text.trim();
if (!value) return;
setTodos((current) => [...current, { id: crypto.randomUUID(), text: value, completed: false }]);
setText("");
}
return (
<main>
<h1>Todos ({remaining} left)</h1>
<input value={text} onChange={(event) => setText(event.target.value)} onKeyDown={(event) => event.key === "Enter" && addTodo()} />
<button type="button" onClick={addTodo}>Add</button>
<ul>
{todos.map((todo) => (
<li key={todo.id}>
<label>
<input
type="checkbox"
checked={todo.completed}
onChange={() => setTodos((current) => current.map((item) => item.id === todo.id ? { ...item, completed: !item.completed } : item))}
/>
{todo.text}
</label>
</li>
))}
</ul>
</main>
);
}Persist and filter (next slice)
type Filter = "all" | "active" | "completed";
// after todos change:
localStorage.setItem("todos", JSON.stringify(todos));
// on first render:
const [todos, setTodos] = useState<Todo[]>(() => {
try {
return JSON.parse(localStorage.getItem("todos") ?? "[]") as Todo[];
} catch {
return [];
}
});Filter with a derived list: todos.filter(...) based on a Filter state—do not keep a second “visibleTodos” array in sync by hand.
Next improvements
- Extract
TodoListandTodoItemcomponents - Add Vitest + Testing Library for add/toggle
- Disable Add when the input is empty
- Announce remaining count to assistive tech
Practice checklist
- Add, toggle, and delete a todo without TypeScript errors
- Reload the page and keep todos via
localStorage - Add All / Active / Completed filters
Sources and further reading
Who this guide is for
- Developers learning React and TypeScript together
- Frontend engineers practicing predictable state updates
- Teams needing a small reference app for component and test conventions
Definition of done
An accessible Todo application with typed domain data, add/toggle/delete/filter flows, safe persistence, empty-state handling, and tests for the primary user journey.
Finishing the commands is not the same as finishing the guide. Keep the result only when another person can reproduce it, inspect the evidence, and understand the remaining risks.
Professional workflow
Phase 1 — Define the product slice
Goal: Build one complete workflow before adding architecture.
- Write the Todo and Filter types.
- Define acceptance criteria for add, toggle, delete, and filter.
- Choose stable IDs and a single owner for the collection.
Verification: Every acceptance criterion maps to a visible interaction and expected screen state.
Phase 2 — Build accessible interactions
Goal: Make the app usable without relying on pointer-only behavior.
- Use a labeled form so Enter submits naturally.
- Connect each checkbox to visible text.
- Announce remaining-item changes without stealing focus.
Verification: The full workflow works using only the keyboard and has no unlabeled controls.
Phase 3 — Persist and protect data
Goal: Treat browser storage as an unreliable boundary.
- Validate parsed storage before using it.
- Version the stored shape if future changes are likely.
- Provide a clear recovery path for malformed data.
Verification: Corrupted storage produces a safe empty state rather than a broken application.
Review questions before you continue
- Which fields belong to the domain rather than the UI?
- Can filtered items be derived instead of synchronized?
- What should happen when text is blank or duplicated?
- How will persisted data migrate when the model changes?
If an answer is unknown, record it as an explicit follow-up instead of hiding the uncertainty behind a successful demo.
Troubleshooting decision guide
- List items update incorrectly: verify immutable updates and stable keys.
- TypeScript widens a filter to string: declare the union at the state boundary.
- Saved data crashes startup: parse inside a guarded initializer and validate the result.
- Focus becomes confusing after delete: choose and test a deliberate focus destination.
Change one variable at a time, preserve the first useful error, and write down the command or condition that fixed it. That turns a private debugging session into team knowledge.
Production-readiness checklist
- Validate all external data, including localStorage.
- Add component tests for the critical user path.
- Include empty, error, and long-text visual states.
- Check contrast, labels, focus order, and reduced motion.
- Avoid adding global state until multiple distant consumers genuinely need it.
These checks are intentionally stricter than a beginner demo. Use them to decide whether to continue learning, run a controlled pilot, or request specialist review.
Your next 30 minutes
- Complete the keyboard-only user journey.
- Add one test covering add, toggle, filter, and reload.
- Document the stored schema and recovery behavior.
- Use the finished app as a template for your next bounded React feature.
CTA: Pick the first unchecked step, complete it now, and save the evidence in your project README or decision log. Then open the official documentation linked below before adopting the result in production.