First App
Build a task list that covers the React fundamentals: components, state, events, data fetching with the use hook, Suspense, and form actions.
1. The Task Type
// src/types/task.ts
export interface Task {
id: string;
title: string;
completed: boolean;
createdAt: string;
}
2. Task Item Component
Start with a simple component that displays a task and handles toggle/delete.
// src/components/TaskItem.tsx
import type { Task } from "@/types/task";
interface TaskItemProps {
task: Task;
onToggle: (id: string) => void;
onDelete: (id: string) => void;
}
function TaskItem({ task, onToggle, onDelete }: TaskItemProps) {
return (
<li className="flex items-center gap-3 p-3 rounded-lg bg-zinc-900">
<input
type="checkbox"
checked={task.completed}
onChange={() => onToggle(task.id)}
className="h-5 w-5 accent-blue-500"
/>
<span
className={`flex-1 ${task.completed ? "line-through text-zinc-500" : "text-zinc-100"}`}
>
{task.title}
</span>
<button
onClick={() => onDelete(task.id)}
className="text-zinc-500 hover:text-red-400 text-sm"
>
Delete
</button>
</li>
);
}
export { TaskItem };
3. Task List with State
// src/components/TaskList.tsx
import { useState } from "react";
import { TaskItem } from "./TaskItem";
import type { Task } from "@/types/task";
function TaskList({ initialTasks }: { initialTasks: Task[] }) {
const [tasks, setTasks] = useState<Task[]>(initialTasks);
function toggleTask(id: string) {
setTasks((prev) =>
prev.map((t) => (t.id === id ? { ...t, completed: !t.completed } : t))
);
}
function deleteTask(id: string) {
setTasks((prev) => prev.filter((t) => t.id !== id));
}
function addTask(title: string) {
const newTask: Task = {
id: crypto.randomUUID(),
title,
completed: false,
createdAt: new Date().toISOString(),
};
setTasks((prev) => [newTask, ...prev]);
}
const pending = tasks.filter((t) => !t.completed).length;
return (
<div className="space-y-4">
<div className="flex items-center justify-between">
<h2 className="text-xl font-semibold text-zinc-100">
Tasks ({pending} remaining)
</h2>
</div>
<AddTaskForm onAdd={addTask} />
<ul className="space-y-2">
{tasks.map((task) => (
<TaskItem
key={task.id}
task={task}
onToggle={toggleTask}
onDelete={deleteTask}
/>
))}
</ul>
{tasks.length === 0 && (
<p className="text-zinc-500 text-center py-8">
No tasks yet. Add one above.
</p>
)}
</div>
);
}
export { TaskList };
4. Add Task Form
Use the action prop on forms (React 19) instead of onSubmit with preventDefault:
// src/components/AddTaskForm.tsx
import { useRef } from "react";
function AddTaskForm({ onAdd }: { onAdd: (title: string) => void }) {
const inputRef = useRef<HTMLInputElement>(null);
function handleSubmit(formData: FormData) {
const title = formData.get("title") as string;
if (!title.trim()) return;
onAdd(title.trim());
// Clear the input after adding
if (inputRef.current) {
inputRef.current.value = "";
inputRef.current.focus();
}
}
return (
<form action={handleSubmit} className="flex gap-2">
<input
ref={inputRef}
name="title"
type="text"
placeholder="Add a task..."
className="flex-1 px-4 py-2 bg-zinc-800 text-zinc-100 rounded-lg border border-zinc-700 focus:border-blue-500 focus:outline-none"
autoFocus
/>
<button
type="submit"
className="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-500 transition-colors"
>
Add
</button>
</form>
);
}
export { AddTaskForm };
Tip: The
actionprop on<form>is a React 19 feature. It receivesFormDatadirectly, no need fore.preventDefault(). It also works with progressive enhancement - the form submits even before JavaScript loads.
5. Data Fetching with use and Suspense
Fetch initial tasks from an API using the use hook:
// src/lib/api.ts
import type { Task } from "@/types/task";
export function fetchTasks(): Promise<Task[]> {
return fetch("/api/tasks")
.then((res) => {
if (!res.ok) throw new Error("Failed to fetch tasks");
return res.json();
});
}
// For development - mock API with a delay
export function fetchMockTasks(): Promise<Task[]> {
return new Promise((resolve) => {
setTimeout(() => {
resolve([
{ id: "1", title: "Learn React 19", completed: false, createdAt: "2025-01-01" },
{ id: "2", title: "Try the use hook", completed: false, createdAt: "2025-01-02" },
{ id: "3", title: "Set up Vite", completed: true, createdAt: "2025-01-03" },
]);
}, 1000); // simulate network delay
});
}
// src/components/TaskLoader.tsx
import { use, Suspense } from "react";
import { TaskList } from "./TaskList";
import { fetchMockTasks } from "@/lib/api";
import type { Task } from "@/types/task";
// Create the promise OUTSIDE the component
// so it doesn't restart on every render
const tasksPromise = fetchMockTasks();
function TasksContent({ promise }: { promise: Promise<Task[]> }) {
const tasks = use(promise); // suspends until resolved
return <TaskList initialTasks={tasks} />;
}
function TaskLoader() {
return (
<Suspense
fallback={
<div className="space-y-3">
{[1, 2, 3].map((i) => (
<div key={i} className="h-12 bg-zinc-800 rounded-lg animate-pulse" />
))}
</div>
}
>
<TasksContent promise={tasksPromise} />
</Suspense>
);
}
export { TaskLoader };
Gotcha: The promise must be created outside the component or cached. If you call
fetchMockTasks()insideTasksContent, every suspend re-creates the promise, causing an infinite loop.
6. Error Boundary
Handle fetch failures gracefully:
// src/components/ErrorBoundary.tsx
import { Component, type ReactNode } from "react";
interface Props {
fallback: ReactNode;
children: ReactNode;
}
interface State {
hasError: boolean;
}
class ErrorBoundary extends Component<Props, State> {
constructor(props: Props) {
super(props);
this.state = { hasError: false };
}
static getDerivedStateFromError(): State {
return { hasError: true };
}
render() {
if (this.state.hasError) {
return this.props.fallback;
}
return this.props.children;
}
}
export { ErrorBoundary };
Tip: Error boundaries are still class components in React 19. There’s no hook equivalent yet. This is the one place classes are still required.
7. Wire It All Together
// src/App.tsx
import { TaskLoader } from "@/components/TaskLoader";
import { ErrorBoundary } from "@/components/ErrorBoundary";
function App() {
return (
<main className="min-h-screen bg-zinc-950 text-zinc-100 p-8">
<div className="max-w-lg mx-auto">
<h1 className="text-3xl font-bold mb-8">Task List</h1>
<ErrorBoundary
fallback={
<div className="text-red-400 p-4 bg-red-950 rounded-lg">
Failed to load tasks. Refresh to try again.
</div>
}
>
<TaskLoader />
</ErrorBoundary>
</div>
</main>
);
}
export default App;
What You’ve Covered
| Concept | Where |
|---|---|
| Components as functions | TaskItem, AddTaskForm |
| Props (data down) | task, onToggle, onDelete |
| Callbacks (events up) | onAdd, onToggle, onDelete |
useState | TaskList managing the task array |
useRef | AddTaskForm clearing the input |
| Form actions (React 19) | <form action={handleSubmit}> |
use hook (React 19) | TasksContent reading a Promise |
| Suspense | TaskLoader showing skeleton while loading |
| Error boundaries | ErrorBoundary catching fetch failures |
| Conditional rendering | Empty state, completed styles |
| List rendering with keys | tasks.map() with key={task.id} |
Next Steps
- Add persistence with
localStorage(custom hook from Hooks) - Add filtering (all / active / completed) with derived state
- Replace mock data with a real API or TanStack Query (Data Fetching)
- Add optimistic updates for toggle/delete (Forms and Actions)
Next: Data Fetching for production patterns