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 action prop on <form> is a React 19 feature. It receives FormData directly, no need for e.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() inside TasksContent, 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

ConceptWhere
Components as functionsTaskItem, AddTaskForm
Props (data down)task, onToggle, onDelete
Callbacks (events up)onAdd, onToggle, onDelete
useStateTaskList managing the task array
useRefAddTaskForm clearing the input
Form actions (React 19)<form action={handleSubmit}>
use hook (React 19)TasksContent reading a Promise
SuspenseTaskLoader showing skeleton while loading
Error boundariesErrorBoundary catching fetch failures
Conditional renderingEmpty state, completed styles
List rendering with keystasks.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