Data Fetching

Three patterns for fetching data in React 19, from simplest to most powerful. Pick based on your app’s needs.

Pattern 1: use Hook + Suspense

React 19’s native approach. No libraries needed. Best for simple fetch-and-display.

import { use, Suspense } from "react";

async function fetchPosts(): Promise<Post[]> {
  const res = await fetch("/api/posts");
  if (!res.ok) throw new Error("Failed to fetch");
  return res.json();
}

// Cache the promise to avoid re-fetching on every render
let postsPromise: Promise<Post[]> | null = null;

function getPostsPromise() {
  if (!postsPromise) {
    postsPromise = fetchPosts();
  }
  return postsPromise;
}

function PostList() {
  const posts = use(getPostsPromise()); // suspends until resolved

  return (
    <ul>
      {posts.map((post) => (
        <li key={post.id}>{post.title}</li>
      ))}
    </ul>
  );
}

function PostsPage() {
  return (
    <Suspense fallback={<PostsSkeleton />}>
      <PostList />
    </Suspense>
  );
}

Nested Suspense for Parallel Fetching

Each Suspense boundary fetches independently. No waterfalls.

function Dashboard() {
  return (
    <div className="grid grid-cols-2 gap-4">
      <Suspense fallback={<Skeleton />}>
        <RevenueChart promise={fetchRevenue()} />
      </Suspense>
      <Suspense fallback={<Skeleton />}>
        <UserStats promise={fetchUsers()} />
      </Suspense>
      <Suspense fallback={<Skeleton />}>
        <RecentOrders promise={fetchOrders()} />
      </Suspense>
    </div>
  );
}

All three fetches start simultaneously. Each section appears as soon as its data is ready.

Gotcha: Pass the promise as a prop. If you call fetchRevenue() inside RevenueChart, the component suspends, re-renders, calls fetchRevenue() again - infinite loop. Create the promise in the parent.

Invalidation

With plain use, you handle cache invalidation yourself:

function PostsPage() {
  const [key, setKey] = useState(0);

  // Create a new promise when key changes
  const postsPromise = useMemo(() => fetchPosts(), [key]);

  return (
    <div>
      <button onClick={() => setKey((k) => k + 1)}>Refresh</button>
      <Suspense fallback={<Skeleton />}>
        <PostList promise={postsPromise} />
      </Suspense>
    </div>
  );
}

Pattern 2: TanStack Query

For complex client-side data needs: caching, pagination, optimistic updates, background refetching.

npm install @tanstack/react-query

Setup

// src/main.tsx
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";

const queryClient = new QueryClient({
  defaultOptions: {
    queries: {
      staleTime: 60_000, // data fresh for 1 minute
      retry: 2,
    },
  },
});

createRoot(document.getElementById("root")!).render(
  <QueryClientProvider client={queryClient}>
    <App />
  </QueryClientProvider>
);

Basic Query

import { useQuery } from "@tanstack/react-query";

function PostList() {
  const { data: posts, isPending, error } = useQuery({
    queryKey: ["posts"],
    queryFn: () => fetch("/api/posts").then((r) => r.json()),
  });

  if (isPending) return <Skeleton />;
  if (error) return <ErrorMessage error={error} />;

  return (
    <ul>
      {posts.map((post: Post) => (
        <li key={post.id}>{post.title}</li>
      ))}
    </ul>
  );
}

Mutations with Optimistic Updates

import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";

function TaskList() {
  const queryClient = useQueryClient();
  const { data: tasks } = useQuery({
    queryKey: ["tasks"],
    queryFn: fetchTasks,
  });

  const toggleMutation = useMutation({
    mutationFn: (id: string) =>
      fetch(`/api/tasks/${id}/toggle`, { method: "PATCH" }),

    // Optimistic update
    onMutate: async (id) => {
      await queryClient.cancelQueries({ queryKey: ["tasks"] });
      const previous = queryClient.getQueryData<Task[]>(["tasks"]);

      queryClient.setQueryData<Task[]>(["tasks"], (old) =>
        old?.map((t) =>
          t.id === id ? { ...t, completed: !t.completed } : t
        )
      );

      return { previous };
    },
    onError: (_err, _id, context) => {
      // Rollback on error
      queryClient.setQueryData(["tasks"], context?.previous);
    },
    onSettled: () => {
      // Refetch to sync with server
      queryClient.invalidateQueries({ queryKey: ["tasks"] });
    },
  });

  return (
    <ul>
      {tasks?.map((task) => (
        <li key={task.id} onClick={() => toggleMutation.mutate(task.id)}>
          {task.completed ? "✓" : "○"} {task.title}
        </li>
      ))}
    </ul>
  );
}

Pagination

import { useQuery, keepPreviousData } from "@tanstack/react-query";

function PaginatedPosts() {
  const [page, setPage] = useState(1);

  const { data, isPending, isPlaceholderData } = useQuery({
    queryKey: ["posts", page],
    queryFn: () => fetchPosts(page),
    placeholderData: keepPreviousData, // show old data while fetching new page
  });

  return (
    <div>
      <ul className={isPlaceholderData ? "opacity-50" : ""}>
        {data?.posts.map((post: Post) => (
          <li key={post.id}>{post.title}</li>
        ))}
      </ul>
      <div className="flex gap-2">
        <button disabled={page === 1} onClick={() => setPage((p) => p - 1)}>
          Previous
        </button>
        <span>Page {page}</span>
        <button
          disabled={isPlaceholderData || !data?.hasMore}
          onClick={() => setPage((p) => p + 1)}
        >
          Next
        </button>
      </div>
    </div>
  );
}

Pattern 3: Server Components

The best option when you have a framework (Next.js). Data fetches on the server, zero client JS for the fetching logic.

// app/posts/page.tsx (Next.js App Router)
import { db } from "@/lib/db";

// This is a server component - no "use client"
// It runs on the server, accesses the DB directly
export default async function PostsPage() {
  const posts = await db.posts.findMany({
    orderBy: { createdAt: "desc" },
  });

  return (
    <main>
      <h1>Posts</h1>
      <ul>
        {posts.map((post) => (
          <li key={post.id}>
            <a href={`/posts/${post.id}`}>{post.title}</a>
          </li>
        ))}
      </ul>
    </main>
  );
}

With Loading States (Suspense)

// app/dashboard/page.tsx
import { Suspense } from "react";

export default function DashboardPage() {
  return (
    <div>
      <h1>Dashboard</h1>
      <Suspense fallback={<ChartSkeleton />}>
        <RevenueChart />
      </Suspense>
      <Suspense fallback={<TableSkeleton />}>
        <RecentOrders />
      </Suspense>
    </div>
  );
}

// Each async component streams in as its data resolves
async function RevenueChart() {
  const data = await db.revenue.getMonthly(); // takes 2s
  return <Chart data={data} />;
}

async function RecentOrders() {
  const orders = await db.orders.findMany({ take: 10 }); // takes 500ms
  return <OrderTable orders={orders} />;
}

Comparison Table

Featureuse + SuspenseTanStack QueryServer Components
SetupNoneProvider + clientFramework required
Bundle size0 KB~13 KB0 KB
CachingManualAutomaticFramework handles
Background refetchNoYesNo
Optimistic updatesManual (useOptimistic)Built-inVia server actions
PaginationManualBuilt-in helpersManual
Offline supportNoYesNo
SSR compatibleYesYesNative
Best forSimple fetch-and-displayComplex client appsFull-stack apps with a framework

Error Handling with Error Boundaries

All three patterns work with error boundaries for error display:

import { ErrorBoundary } from "react-error-boundary";

function App() {
  return (
    <ErrorBoundary
      fallbackRender={({ error, resetErrorBoundary }) => (
        <div className="p-4 bg-red-950 text-red-200 rounded-lg">
          <p>Something went wrong: {error.message}</p>
          <button onClick={resetErrorBoundary}>Try again</button>
        </div>
      )}
      onReset={() => {
        // Clear cache, refetch, etc.
      }}
    >
      <Suspense fallback={<Loading />}>
        <DataComponent />
      </Suspense>
    </ErrorBoundary>
  );
}

Tip: The react-error-boundary package gives you a functional API and reset capabilities. It’s a thin wrapper around the class-based error boundary.

Decision Guide

  1. Building a full-stack app with Next.js? Use server components for initial data, server actions for mutations.
  2. Client-side app with simple data needs? Use use + Suspense. It’s built in, zero dependencies.
  3. Client-side app with complex data (pagination, caching, optimistic updates)? Use TanStack Query. The complexity pays for itself.
  4. Mixing approaches? Totally fine. Server components for initial page data, TanStack Query for interactive client-side features.

Next: State Management for managing state beyond fetched data