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()insideRevenueChart, the component suspends, re-renders, callsfetchRevenue()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
| Feature | use + Suspense | TanStack Query | Server Components |
|---|---|---|---|
| Setup | None | Provider + client | Framework required |
| Bundle size | 0 KB | ~13 KB | 0 KB |
| Caching | Manual | Automatic | Framework handles |
| Background refetch | No | Yes | No |
| Optimistic updates | Manual (useOptimistic) | Built-in | Via server actions |
| Pagination | Manual | Built-in helpers | Manual |
| Offline support | No | Yes | No |
| SSR compatible | Yes | Yes | Native |
| Best for | Simple fetch-and-display | Complex client apps | Full-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-boundarypackage gives you a functional API and reset capabilities. It’s a thin wrapper around the class-based error boundary.
Decision Guide
- Building a full-stack app with Next.js? Use server components for initial data, server actions for mutations.
- Client-side app with simple data needs? Use
use+ Suspense. It’s built in, zero dependencies. - Client-side app with complex data (pagination, caching, optimistic updates)? Use TanStack Query. The complexity pays for itself.
- 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