The Read side of the UI: load a collection, handle the three states, and keep it fresh.
1if (isLoading) return <Spinner />;
2if (error) return <ErrorBox onRetry={refetch} />;
3if (data.length === 0) return <EmptyState />;
4return <TaskList tasks={data} />;Loading, error, and empty are not edge cases — they're the normal lifecycle of remote data. Design all three.
Hand-rolling useEffect + useState for every fetch leads to bugs (race conditions, stale closures, no caching). Libraries like TanStack Query or SWR handle caching, refetching, and dedup:
1import { useQuery } from "@tanstack/react-query";
2
3function useTasks(status?: string) {
4 return useQuery({
5 queryKey: ["tasks", status],
6 queryFn: () =>
7 fetch(`/api/v1/tasks?status=${status ?? ""}`).then((r) => r.json()),
8 });
9}The queryKey doubles as a cache key — change the filter, get a fresh (cached) result.
In the Next.js App Router you can read data directly in a Server Component — no client fetch, no loading spinner on first paint:
1export default async function TasksPage() {
2 const tasks = await prisma.task.findMany({ where: { ownerId } });
3 return <TaskList tasks={tasks} />;
4}Wire the list to the paginated API from Chapter 8: a "Load more" button (cursor) or page controls (offset). Never render ten thousand DOM nodes — fetch a page at a time.