Concept lesson · explains an idea. Code is shown for reading, not run.
Fetching data inside effects works, and you learned it so you understand what is happening. But look at what a real app needs on top of the basics:
- Caching: do not fetch the same user three times because three components need it.
- Deduplication: if two components ask at the same moment, send one request.
- Refetching: update stale data when the window regains focus or the network returns.
- Retries with sensible delays after failures.
- Pagination and infinite lists with cached pages.
- Optimistic updates and invalidating data after a change.
- Status tracking (loading, error, success) with the same shape everywhere.
Writing all that in each component, or in your own hook, is slow and error-prone. Libraries have solved it.
Popular choices
| Option | What it is |
|---|---|
| TanStack Query (formerly React Query) | A full “server state” library: caching, refetching, mutations, pagination, devtools. Very widely used. |
| SWR | A smaller library from Vercel with the “stale-while-revalidate” idea: show cached data, then refresh it. |
| Router data loaders (React Router, TanStack Router) | The route loads the data before it renders; good for page-level data. |
| Framework data features (Next.js and others) | Fetch on the server and send HTML and data to the browser; see Client-Side React vs React Frameworks. |
use with Suspense | React 19 lets a component read a promise with use, showing a Suspense fallback while it loads. You must create and cache the promise outside the render (a library or framework normally does this). |
| RTK Query, Apollo Client, urql | Options when you already use Redux or GraphQL |
What using one looks like
This is the shape of TanStack Query (shown for orientation; it needs npm install @tanstack/react-query and a provider at the app’s root, which is why it does not run in the playground):
import { useQuery } from '@tanstack/react-query'
function Users() {
const { data, isPending, error } = useQuery({
queryKey: ['users'],
queryFn: () => fetch('/api/users').then((r) => r.json()),
})
if (isPending) return <p>Loading...</p>
if (error) return <p>Could not load users</p>
return <ul>{data.map((u) => <li key={u.id}>{u.name}</li>)}</ul>
}
Compare it with what you wrote by hand in Fetching Data with Effects. The library gives you the status, data and error, and in addition caches by queryKey, avoids duplicate calls and refetches in the background. Check each library’s current documentation for exact API names, because they change between major versions.
When to stay with plain fetch
- A small app with one or two requests.
- A single component that loads once and never refreshes.
- Learning, and demos like the ones in this course.
When to adopt a library
- The same data appears on several screens.
- You need background refresh, retries or pagination.
- You find yourself rebuilding caches and loading flags.
- Several developers need one consistent way to handle server data.
Server state vs client state
A helpful idea: data that lives on the server and is only borrowed by the browser (users, products, orders) is server state. It can go stale, be changed by others and needs caching. Data that exists only in the browser (is the menu open, what is typed in a field) is client state. Use a data library for server state and useState or useReducer for client state. Mixing them in one big global store is a common source of complexity.
Key points
- Libraries add caching, deduplication, refetching and retries on top of
fetch. - Popular choices are TanStack Query, SWR and router or framework data loaders.
- Use plain
fetchfor small cases; adopt a library when server data is shared or needs refreshing.