Part 1.5 — Building a Small Interactive App: A Type-Safe Todo List
A Todo List may sound simple, but it’s the perfect sandbox to combine the essentials you’ve learned so far: components, props, events, state, controlled inputs, and data flow.
By the end of this post, you’ll have a compact, production-minded mini-app you can scale when we connect it to your NestJS backend later.
1. Plan the App (Like a Real Engineer)
Before you write code, outline what the app needs to do:
Display a list of todos
Allow adding new todos
Allow toggling a todo as complete
Allow deleting a todo
Keep everything strongly typed
We’ll split functionality into clear, testable pieces:
A TodoItem component
A TodoList component
An AddTodoForm
A TodoApp wrapper that owns the state
This mirrors real-world frontend architecture.
2. Define the Todo Type
Every layer — frontend now, backend soon — should use clear contracts.
Start by defining a Todo type.
// src/types/todo.ts
export interface Todo {
id: number;
text: string;
completed: boolean;
}Strong typing now prevents future drift when we add REST endpoints.
3. Build the AddTodoForm Component
This component handles user input and emits new todos upward.
// src/components/AddTodoForm.tsx
import { useState } from 'react';
interface AddTodoFormProps {
onAdd: (text: string) => void;
}
export function AddTodoForm({ onAdd }: AddTodoFormProps) {
const [text, setText] = useState('');
const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
if (!text.trim()) return;
onAdd(text.trim());
setText('');
};
return (
<form onSubmit={handleSubmit} className="flex gap-2 mt-4">
<input
className="border rounded p-2 flex-1"
value={text}
onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
setText(e.target.value)
}
placeholder="Add a new todo..."
/>
<button className="px-4 py-2 bg-blue-600 text-white rounded">
Add
</button>
</form>
);
}This structure is predictable, testable, and scalable.
4. Build the TodoItem Component
A single todo needs to:
Render text
Toggle completion
Delete itself
// src/components/TodoItem.tsx
import type { Todo } from '../types/todo';
interface TodoItemProps {
todo: Todo;
onToggle: (id: number) => void;
onDelete: (id: number) => void;
}
export function TodoItem({ todo, onToggle, onDelete }: TodoItemProps) {
return (
<li className="flex items-center justify-between py-1">
<label className="flex items-center gap-2">
<input
type="checkbox"
checked={todo.completed}
onChange={() => onToggle(todo.id)}
/>
<span className={todo.completed ? 'line-through text-gray-500' : ''}>
{todo.text}
</span>
</label>
<button
onClick={() => onDelete(todo.id)}
className="text-red-500 hover:underline"
>
Delete
</button>
</li>
);
}This component stays small and focused — ideal for testing.
5. Build the TodoList Component
This component receives todos and renders a list of <TodoItem />.
// src/components/TodoList.tsx
import type { Todo } from '../types/todo';
import { TodoItem } from './TodoItem';
interface TodoListProps {
todos: Todo[];
onToggle: (id: number) => void;
onDelete: (id: number) => void;
}
export function TodoList({ todos, onToggle, onDelete }: TodoListProps) {
if (todos.length === 0) {
return <p className="mt-4 text-gray-500">No todos yet.</p>;
}
return (
<ul className="mt-4 space-y-2">
{todos.map(todo => (
<TodoItem
key={todo.id}
todo={todo}
onToggle={onToggle}
onDelete={onDelete}
/>
))}
</ul>
);
}This keeps rendering and data manipulation separate.
6. Build the Main TodoApp Component (State Lives Here)
This is where everything comes together.
// src/components/TodoApp.tsx
import { useState } from 'react';
import type { Todo } from '../types/todo';
import { AddTodoForm } from './AddTodoForm';
import { TodoList } from './TodoList';
export function TodoApp() {
const [todos, setTodos] = useState<Todo[]>([]);
const addTodo = (text: string) => {
setTodos(prev => [
...prev,
{ id: Date.now(), text, completed: false }
]);
};
const toggleTodo = (id: number) => {
setTodos(prev =>
prev.map(todo =>
todo.id === id
? { ...todo, completed: !todo.completed }
: todo
)
);
};
const deleteTodo = (id: number) => {
setTodos(prev => prev.filter(todo => todo.id !== id));
};
return (
<section className="max-w-md mx-auto mt-10 p-6 bg-white rounded shadow">
<h1 className="text-2xl font-bold">Todo List</h1>
<AddTodoForm onAdd={addTodo} />
<TodoList todos={todos} onToggle={toggleTodo} onDelete={deleteTodo} />
</section>
);
}Every action (add, toggle, delete) is type-checked and predictable.
7. Render It in Your App
Inside your App.tsx:
import { TodoApp } from './components/TodoApp';
export default function App() {
return (
<main className="min-h-screen bg-gray-100">
<TodoApp />
</main>
);
}Run:
pnpm devYou now have a complete Todo app.
8. Why This Matters for Full-Stack Work
In upcoming parts, you’ll replace local state with:
REST API calls to your NestJS backend
Prisma-backed PostgreSQL persistence
DTO validation for security
Jest + Playwright tests
This small app becomes the perfect testbed for real CRUD operations connected to your database.
9. Wrap-Up
You now know how to:
Combine components, props, events, and state
Design reusable UI structures
Build type-safe, predictable React features
Create a working interactive mini-application
In Part 1.6, we shift gears: you’ll learn how to test React components using Jest and React Testing Library — the preview for the deeper testing work coming in Part 5.
Related
Part 2.5 — Consuming REST APIs with Shared Types & Error Handling Strategies
Real-world frontends need safety: typed API responses, predictable errors, and consistent client logic. This post teaches you the patterns professionals use to integrate React with REST cleanly.
Part 2.4 — UX State: Loading, Error, and Empty States in React
A React app becomes truly usable when it handles all states gracefully: loading, error, empty, and success. In this post, you’ll learn the UX patterns professionals rely on.
Part 2.3 — Handling Forms, Validation & Submission to REST Endpoints
Forms connect users to data. In this post, you’ll learn how to validate input, manage UX states, and submit cleanly typed data to real REST endpoints.
Comments