Figuring out how to fetch data in React used to mean reaching for useEffect and a fetch call on day one. In 2026 that is often the wrong first move. Between Server Components, the use() hook, and mature caching libraries, the right answer now depends on where your data lives and whether you are running on a framework. This guide walks the real options, honestly, and flags what to skip.
What changed in 2026
- React Server Components (RSC) are the default in Next.js and most meta-frameworks. You can fetch data directly in an async component that runs on the server — no
useEffect, no loading state, no client waterfall.
- The
use() hook reads promises. Client components can now unwrap a promise directly and let Suspense handle the loading UI.
- TanStack Query (formerly React Query) v5 is the de facto client-side standard. SWR still works and is lighter, but Query has the larger ecosystem.
- Framework
fetch has caching semantics. In Next.js, the built-in fetch is wrapped with caching and revalidation rules, so the same call behaves differently than a raw browser fetch. Read your framework docs — the defaults have shifted more than once.
The four ways to fetch, compared
| Approach |
Runs where |
Caching |
Best for |
| Async Server Component |
Server |
Framework-managed |
Initial page data on a framework |
| TanStack Query |
Client |
Built-in, excellent |
Interactive client data you refetch |
use() + Suspense |
Client |
You provide the promise |
Streaming a server promise to the client |
Plain fetch in a handler |
Client |
None |
One-off writes, form submits |
There is no single winner. The honest rule: server-render what you can, use a query library for interactive client data, and drop to raw fetch only for fire-and-forget calls.
Option 1: async Server Components
If you are on Next.js 15+, Remix, or another RSC-capable framework, this is the simplest correct answer:
// app/users/page.tsx (Server Component)
export default async function UsersPage() {
const res = await fetch("https://api.example.com/users", {
next: { revalidate: 60 }, // Next.js: cache for 60s
});
const users = await res.json();
return (
<ul>
{users.map((u) => (
<li key={u.id}>{u.name}</li>
))}
</ul>
);
}
No useState, no useEffect, no loading flag. The data is fetched before the HTML is sent, so there is nothing to hydrate a spinner for. The catch: this only works in Server Components. The moment you add "use client", you lose it.
Option 2: TanStack Query for client data
For data that changes as users interact — dashboards, search, anything you refetch — TanStack Query is worth the dependency:
"use client";
import { useQuery } from "@tanstack/react-query";
function Profile({ id }: { id: string }) {
const { data, isPending, error } = useQuery({
queryKey: ["user", id],
queryFn: () => fetch(`/api/user/${id}`).then((r) => r.json()),
});
if (isPending) return <p>Loading…</p>;
if (error) return <p>Failed to load</p>;
return <h1>{data.name}</h1>;
}
You get caching, deduplication, background refetching, and stale-while-revalidate for free. Rebuilding that correctly in useEffect is a surprising amount of code, and most hand-rolled versions have race-condition bugs.
Option 3: the use() hook and Suspense
React's use() hook unwraps a promise inside a client component. Pass a promise down from a Server Component and let Suspense show the fallback:
"use client";
import { use } from "react";
function Comments({ promise }) {
const comments = use(promise); // suspends until resolved
return <ul>{comments.map((c) => <li key={c.id}>{c.text}</li>)}</ul>;
}
Wrap it in <Suspense fallback={…}> on the parent. This is powerful for streaming, but it is newer and easier to misuse — do not create the promise inside render, or you will refetch on every pass.
Option 4: plain fetch (still legitimate)
For a one-off action — submitting a form, deleting a record — a raw fetch in an event handler is the right tool. No library needed:
async function handleSave(data) {
await fetch("/api/save", {
method: "POST",
body: JSON.stringify(data),
});
}
Where plain fetch goes wrong is inside useEffect for data you display: you have to manage loading, errors, cancellation on unmount, and refetching yourself. That is exactly the boilerplate a query library removes.
How to choose
- Initial page data on a framework? → async Server Component.
- Interactive client data you refetch or cache? → TanStack Query.
- Streaming a server promise into a client component? →
use() + Suspense.
- One-off write or action? → plain
fetch.
What to skip
- Raw
useEffect + fetch for displayed data. It works in demos and leaks race conditions in production. Reach for a library or a Server Component instead.
- Redux for server state. Server data is not app state; a query cache models it far better than a global store.
- Rolling your own caching layer. Unless caching is your product, use a maintained library. Verify current bundle sizes and version support yourself before committing.
FAQ
Is useEffect for data fetching dead?
Not dead, but demoted. It is fine for tiny apps and learning, but for anything you refetch, share, or cache, a query library or Server Component is more correct and less code.
TanStack Query or SWR in 2026?
Both are solid. SWR is smaller and simpler; TanStack Query has more features and a bigger ecosystem. For a small app, SWR; for a complex one, Query.
Do Server Components replace TanStack Query?
No — they solve different problems. Server Components handle initial render data; Query handles interactive, client-side data that changes after load. Most real apps use both.
How do I handle loading and error states with Server Components?
Use framework conventions: a loading.tsx file (Next.js) for the Suspense fallback and an error.tsx boundary for failures. You rarely manage them by hand.
Where to go next
If you are still choosing a stack, Astro vs Next.js in 2026 compares the two frameworks that shape how you fetch. When your fetches hit a public API, API rate limiting in 2026 explains the limits you will run into. And to speed up all of this, see the best AI coding assistants in 2026.