Data Fetching with SWR: Revalidation, Mutations, Dependent Requests and Infinite Loading
Key takeaways
Hand-written useEffect fetching repeats loading and error state in every component and refetches data you already have. SWR shows cached data first and revalidates in the background; the post covers global config, mutate and optimistic UI, pagination, retries, prefetching and middleware.
Introduction
SWR is a React Hooks library for data fetching created by Vercel (the team behind Next.js). The name comes from stale-while-revalidate, an HTTP caching strategy that returns cached data immediately while fetching fresh data in the background.
Developed by Vercel’s Shu Ding (also creator of Nextra docs), SWR has become one of the most popular data fetching libraries in the React ecosystem, especially within the Next.js community.
The Problem
Traditional data fetching:
useEffect(() => {
setLoading(true);
fetch('/api/user')
.then(res => res.json())
.then(data => {
setData(data);
setLoading(false);
});
}, []);
Problems:
- Manual loading state
- No caching
- No revalidation
- Boilerplate code
The list understates it. This effect has no error handling (a failed request leaves loading true forever), no cancellation (if the component unmounts or the URL changes, a late response still calls setData — the classic race where a slow response for page 1 overwrites the result for page 2), and no sharing: three components that need the current user make three identical requests. Each of those can be fixed by hand with an AbortController, an ignore flag, and a context, but you end up rebuilding a cache library badly in every project.
The Solution
With SWR:
const { data, error, isLoading } = useSWR('/api/user', fetcher);
The mental model is a global cache keyed by the first argument. The key ('/api/user') identifies the data; the fetcher is just the function SWR calls with that key when it needs fresh data. Every component that calls useSWR with the same key reads the same cache entry and is re-rendered when it changes, so “loading the user” becomes “subscribing to the user”. Keys can also be arrays — useSWR(['/api/user', token], ([url, token]) => fetchWithToken(url, token)) — and SWR hashes them stably, so the same array contents map to the same cache entry.
Where SWR Fits
The Next.js documentation has long used SWR in its client-side fetching examples, and it is common in Vercel’s ecosystem, but nothing about it is Next.js-specific: it works in any React app.
Why developers choose SWR:
- Lightweight: 4KB gzipped (TanStack Query is ~12KB)
- Built by Vercel: Guaranteed Next.js compatibility
- Zero config: Works out of the box with sensible defaults
- Real-time ready: Built-in support for polling and focus revalidation
SWR vs TanStack Query comparison:
| Feature | SWR | TanStack Query |
|---|---|---|
| Bundle size | 4KB | 12KB |
| Learning curve | Simpler | More features, steeper |
| Pagination | useSWRInfinite | useInfiniteQuery with more controls |
| Devtools | Community extensions | Official devtools |
| Best for | Read-heavy screens, simple mutations | Complex mutations and cache invalidation |
When to use SWR:
- Mostly reading data and keeping it fresh
- Need a lightweight solution with a small API surface
- Simple CRUD operations
- Real-time features (focus tracking, polling)
When to use TanStack Query instead:
- Many interdependent mutations that must invalidate groups of queries (query-key prefixes,
invalidateQueries) - Want official dev tools for inspecting the cache
- Need fine-grained control over garbage collection and stale times per query
- Want the same library across React, Vue, Solid, and Svelte
The honest difference is less about features — both do caching, deduplication, pagination, infinite loading, and prefetching — and more about philosophy. SWR keeps the API tiny and treats “revalidate” as the answer to most questions. TanStack Query models queries and mutations as first-class objects with explicit lifecycles, which is more to learn but pays off when an app has many writes. See TanStack Query for that side.
Installation
npm install swr
Basic Usage
Simple Fetching
import useSWR from 'swr';
// Fetcher function
const fetcher = (url: string) => fetch(url).then(res => res.json());
function Profile() {
const { data, error, isLoading } = useSWR('/api/user', fetcher);
if (isLoading) return <div>Loading...</div>;
if (error) return <div>Failed to load</div>;
return <div>Hello, {data.name}!</div>;
}
This fetcher has a flaw that almost every SWR tutorial repeats: fetch does not reject on HTTP errors. A 404 or 500 response resolves normally, res.json() parses the error body (or throws a SyntaxError: Unexpected token '<' if the server returned an HTML error page), and SWR caches the error payload as if it were data. The error branch only runs for network failures. In real code, throw on non-2xx responses:
const fetcher = async (url: string) => {
const res = await fetch(url);
if (!res.ok) {
const error = new Error(`Request failed: ${res.status}`) as Error & { status?: number };
error.status = res.status;
throw error;
}
return res.json();
};
The status property is what makes the retry logic under Error Handling work; without it, error.status === 404 is never true.
The difference between isLoading and isValidating also matters early. isLoading is true only when there is a request in flight and no cached data yet — the first load. isValidating is true for any request in flight, including background revalidations while stale data is displayed. Showing a full-page spinner on isValidating throws away the main benefit of SWR.
With TypeScript
interface User {
id: number;
name: string;
email: string;
}
function Profile() {
const { data, error, isLoading } = useSWR<User>(
'/api/user',
fetcher
);
return <div>{data?.name}</div>;
}
The generic only asserts the shape; SWR does not validate the response. If the API changes, TypeScript still believes data is a User. For data from outside your control, parse it in the fetcher (for example with Zod) so a contract change fails loudly in one place. Also note data is User | undefined: it is undefined during the first load and whenever the key is null, which is why the optional chaining is required.
Global Configuration
// app/providers.tsx (Next.js App Router) — must be a Client Component
'use client';
import { SWRConfig } from 'swr';
export function Providers({ children }: { children: React.ReactNode }) {
return (
<SWRConfig
value={{
fetcher: (url: string) => fetch(url).then(res => res.json()),
revalidateOnFocus: false,
revalidateOnReconnect: true,
}}
>
{children}
</SWRConfig>
);
}
Then render <Providers>{children}</Providers> inside app/layout.tsx. The indirection is necessary: layout.tsx is a Server Component by default, and putting <SWRConfig value={{ fetcher: ... }}> there fails with Functions cannot be passed directly to Client Components unless you explicitly expose it by marking it with "use server", because a function cannot be serialized across the server/client boundary. A small 'use client' providers file is the standard pattern for every context-based library in the App Router.
Now you don’t need to pass fetcher every time:
const { data } = useSWR('/api/user'); // Fetcher is global
Configuration merges from the outside in: nested SWRConfig providers override their parents, and options passed to an individual useSWR call override both. That makes it practical to set conservative defaults globally (for example revalidateOnFocus: false for a dashboard with expensive queries) and turn behavior back on per hook where freshness matters.
Mutations
Basic Mutation
import useSWR, { mutate } from 'swr';
function UpdateProfile() {
const { data } = useSWR('/api/user', fetcher);
async function updateUser(newName: string) {
// Update API
await fetch('/api/user', {
method: 'PATCH',
body: JSON.stringify({ name: newName }),
});
// Revalidate
mutate('/api/user');
}
return <button onClick={() => updateUser('New Name')}>Update</button>;
}
The global mutate(key) with no data argument means “this key is stale, refetch it”, and every mounted useSWR('/api/user') re-renders with the new result. It only affects the exact key: mutate('/api/user') does not touch '/api/user?include=teams'. To revalidate a family of keys, pass a filter function — mutate(key => typeof key === 'string' && key.startsWith('/api/user')). For write operations that should expose their own isMutating and error state, SWR 2 also provides useSWRMutation from swr/mutation, which is closer to what TanStack Query calls a mutation.
Optimistic Updates
import useSWR, { mutate } from 'swr';
function TodoList() {
const { data: todos } = useSWR<Todo[]>('/api/todos', fetcher);
async function addTodo(title: string) {
const newTodo = { id: Date.now(), title, completed: false };
// Optimistic update
mutate(
'/api/todos',
async (currentTodos) => {
// Update UI immediately
const updatedTodos = [...(currentTodos || []), newTodo];
// Send request
await fetch('/api/todos', {
method: 'POST',
body: JSON.stringify(newTodo),
});
// Return optimistic data
return updatedTodos;
},
{
optimisticData: [...(todos || []), newTodo],
rollbackOnError: true,
}
);
}
return (
<div>
{todos?.map(todo => <div key={todo.id}>{todo.title}</div>)}
</div>
);
}
Three things happen in order. optimisticData is written to the cache immediately, so the new todo appears before the network request starts. The async function then runs the real request and returns the value to store. If it throws, rollbackOnError: true restores the previous cache state. After that SWR revalidates the key by default, replacing the optimistic list with whatever the server actually has — which is why the fake id: Date.now() is tolerable here: it is overwritten by the real id a moment later.
A failure mode to watch for is two quick additions in a row. Each call computes its optimistic list from the todos value captured at render time, so the second optimistic update can briefly drop the first item until revalidation fixes it. Passing a function to optimisticData (optimisticData: current => [...(current ?? []), newTodo]) computes from the latest cache instead and avoids the flicker. Also note this example sends newTodo without a Content-Type: application/json header; many servers will then ignore the body.
Bound Mutate
import useSWR from 'swr';
function Profile() {
const { data, mutate } = useSWR('/api/user', fetcher);
async function updateUser() {
// Update with bound mutate (no need to pass key)
await mutate(async (user) => {
await fetch('/api/user', { method: 'PATCH', body: JSON.stringify({ name: 'Updated' }) });
return { ...user, name: 'Updated' };
});
}
return <div>{data?.name}</div>;
}
Conditional Fetching
function UserProfile({ userId }: { userId?: number }) {
// Only fetch if userId exists
const { data } = useSWR(
userId ? `/api/users/${userId}` : null,
fetcher
);
return <div>{data?.name}</div>;
}
Dependent Requests
function UserPosts({ userId }: { userId: number }) {
// First request
const { data: user } = useSWR(`/api/users/${userId}`, fetcher);
// Second request depends on first
const { data: posts } = useSWR(
user ? `/api/posts?userId=${user.id}` : null,
fetcher
);
return <div>{posts?.length} posts</div>;
}
A null key means “don’t fetch”, so the second hook waits for the first without any explicit sequencing code. SWR also accepts a function as the key and treats a thrown error inside it as null, so () => `/api/posts?userId=${user.id}` works even while user is undefined. The trade-off is a waterfall: the posts request cannot start until the user response arrives, doubling latency. If the posts endpoint can take userId directly (as it can here, since userId is already a prop), fetch both in parallel and keep dependent keys for cases where the second request genuinely needs data from the first.
Pagination
function UserList() {
const [page, setPage] = useState(1);
const { data, error, isLoading } = useSWR(
`/api/users?page=${page}&limit=20`,
fetcher
);
return (
<div>
{data?.users.map((user: any) => (
<div key={user.id}>{user.name}</div>
))}
<button onClick={() => setPage(page - 1)} disabled={page === 1}>
Previous
</button>
<button onClick={() => setPage(page + 1)}>Next</button>
</div>
);
}
Each page is a separate key, so going back to page 1 renders instantly from cache. The rough edge is moving forward to an uncached page: the key changes, data becomes undefined, and the list flashes empty while loading. Passing { keepPreviousData: true } keeps showing the previous page’s data until the new one arrives, which is usually what users expect from pagination controls. You can also render the next page’s hook in a hidden component to prefetch it.
Infinite Loading
import useSWRInfinite from 'swr/infinite';
function InfiniteList() {
const getKey = (pageIndex: number, previousPageData: any) => {
// Reached the end
if (previousPageData && !previousPageData.hasMore) return null;
// First page
return `/api/users?page=${pageIndex + 1}&limit=20`;
};
const { data, size, setSize, isLoading } = useSWRInfinite(
getKey,
fetcher
);
const users = data ? data.flatMap(page => page.users) : [];
const hasMore = data?.[data.length - 1]?.hasMore;
return (
<div>
{users.map((user) => (
<div key={user.id}>{user.name}</div>
))}
{hasMore && (
<button onClick={() => setSize(size + 1)}>Load More</button>
)}
</div>
);
}
getKey is called for each page index with the previous page’s data, and returning null stops the chain. size is the number of pages SWR should load; setSize(size + 1) asks for one more. For cursor-based APIs, build the key from previousPageData.nextCursor instead of the page index — offset pagination shifts when items are inserted at the top, producing duplicates across pages. By default revalidating an infinite list refetches only the first page (revalidateFirstPage: true) and then compares; revalidateAll: true refetches every loaded page, which is correct but expensive for long lists. Mutating an infinite list is also more awkward than a single key: call the mutate returned by useSWRInfinite rather than the global one, because the internal cache key is not the string your getKey produced.
Real-time Updates
Auto Revalidation
const { data } = useSWR('/api/data', fetcher, {
refreshInterval: 3000, // Refresh every 3 seconds
refreshWhenHidden: false, // Pause when tab hidden
refreshWhenOffline: false, // Pause when offline
});
Polling is the simplest form of “real-time” and often good enough for dashboards, but it scales with the number of open tabs, not with how often data changes. A three-second interval across a few thousand users is a steady request load on your API even when nothing happens. For data that changes rarely but must appear quickly, a push channel (WebSocket or Server-Sent Events) that calls mutate(key) on each event is cheaper; SWR 2 wraps that pattern in useSWRSubscription from swr/subscription.
Manual Revalidation
import { useSWRConfig } from 'swr';
function RefreshButton() {
const { mutate } = useSWRConfig();
return (
<button onClick={() => mutate('/api/user')}>
Refresh
</button>
);
}
Error Handling
Retry on Error
const { data, error } = useSWR('/api/user', fetcher, {
onErrorRetry: (error, key, config, revalidate, { retryCount }) => {
// Never retry on 404
if (error.status === 404) return;
// Only retry 3 times
if (retryCount >= 3) return;
// Retry after 5 seconds
setTimeout(() => revalidate({ retryCount }), 5000);
},
});
By default SWR retries failed requests with exponential backoff, which is sensible for flaky networks and wasteful for errors that will never succeed — 401, 403, 404, and validation errors. Overriding onErrorRetry as above is the fix, and it depends on the fetcher attaching status to the thrown error (see the fetcher under Basic Usage). A related surprise: after an error, SWR keeps the last successful data as well as setting error, so components that check data before error will keep showing stale content without any indication that refreshing failed.
Error Handling
const { data, error } = useSWR('/api/user', fetcher, {
onError: (error, key) => {
console.error('SWR error:', error);
toast.error('Failed to fetch data');
},
onSuccess: (data, key, config) => {
console.log('Data loaded:', data);
},
});
These callbacks fire once per request, not once per component. When five components share a key and the request fails, deduplication means one request and one onError — but if each component passes its own onError, only the one attached to the hook that initiated the request runs. Put side effects like toasts in the global SWRConfig or a single custom hook, not in every consumer.
Prefetching
import { mutate } from 'swr';
function UserList({ users }: { users: User[] }) {
const prefetch = (userId: number) => {
// Prefetch user data
mutate(
`/api/users/${userId}`,
fetch(`/api/users/${userId}`).then(res => res.json())
);
};
return (
<ul>
{users.map((user) => (
<li key={user.id} onMouseEnter={() => prefetch(user.id)}>
<Link to={`/users/${user.id}`}>{user.name}</Link>
</li>
))}
</ul>
);
}
Populating the cache with mutate(key, promise) works, but SWR 2 has a dedicated API for this: preload('/api/users/1', fetcher) from swr starts the request and lets a later useSWR with the same key reuse the in-flight promise. It can even be called outside React, for example in a router loader. Hover prefetching is a cheap win for detail pages, with one caution: debounce it or guard with a “already prefetched” set, or a user sweeping the mouse down a long list fires dozens of requests.
Middleware
import useSWR from 'swr';
// Logging middleware
function logger(useSWRNext: any) {
return (key: any, fetcher: any, config: any) => {
const swr = useSWRNext(key, fetcher, config);
useEffect(() => {
console.log('SWR Request:', key);
}, [key]);
return swr;
};
}
// Use middleware
const { data } = useSWR('/api/user', fetcher, { use: [logger] });
Custom hooks, loading flags and clearing the cache
Keep each key in one custom hook
// hooks/useUser.ts
export function useUser(id: number) {
return useSWR<User>(
id ? `/api/users/${id}` : null,
fetcher,
{
revalidateOnFocus: false,
dedupingInterval: 2000,
}
);
}
// Usage
const { data: user } = useUser(123);
The reason to wrap useSWR is the key, not tidiness. Deduplication, mutate('/api/users/123') and fallback all match on the exact key, so /api/users/123 in one component and /api/users/123/ or /api/users/123?x= in another are separate cache entries that never see each other’s updates. With the key built in one function, every consumer and every mutation agree on it. Options set here, such as revalidateOnFocus: false, also apply to every consumer, so set them per resource rather than per component.
isLoading for the first load, isValidating for background refreshes
function Component() {
const { data, error, isLoading, isValidating } = useSWR(key, fetcher);
// First load
if (isLoading) return <Skeleton />;
// Error state
if (error) return <ErrorMessage error={error} />;
// Background revalidation indicator
return (
<div>
{isValidating && <RefreshIcon className="animate-spin" />}
{data && <DataDisplay data={data} />}
</div>
);
}
Clearing cached data
import { useSWRConfig } from 'swr';
function CacheManager() {
const { cache, mutate } = useSWRConfig();
const clearCache = () => {
// Clear specific key
mutate('/api/user', undefined, { revalidate: false });
// Clear all cache
if (cache instanceof Map) {
cache.clear();
}
};
return <button onClick={clearCache}>Clear Cache</button>;
}
Clearing the Map directly is the part to avoid in practice. It removes entries without notifying mounted hooks, so components keep rendering old data until something else triggers a revalidation. The supported way to reset everything — for example on logout, so the next user never sees the previous user’s data — is mutate(() => true, undefined, { revalidate: false }), which clears every key and updates subscribers. For per-user data, including the user id in the key is an additional safety net.
Next.js Integration
API Routes
// app/api/users/route.ts (Next.js 13+)
export async function GET() {
const users = await prisma.user.findMany();
return Response.json({ users });
}
Client Component
'use client';
import useSWR from 'swr';
export function UserList() {
const { data } = useSWR('/api/users', fetcher);
return (
<ul>
{data?.users.map((user: any) => (
<li key={user.id}>{user.name}</li>
))}
</ul>
);
}
In the App Router, the first question is whether you need SWR for this data at all. A Server Component can await the database directly and send HTML, with no loading state and no API route. SWR earns its place for data that changes while the page is open, depends on client-only state (filters, the logged-in user’s session in the browser), or must refresh on focus. A common hybrid is to fetch on the server and hand the result to SWR as initial data — <SWRConfig value={{ fallback: { '/api/users': users } }}> — so the page renders immediately and SWR takes over revalidation on the client.
Deduplication and focus revalidation
Deduplication
// Multiple components can use the same key
// SWR makes only ONE request
function ComponentA() {
const { data } = useSWR('/api/user', fetcher);
}
function ComponentB() {
const { data } = useSWR('/api/user', fetcher); // Uses same cache
}
Deduplication is time-based: requests for the same key within dedupingInterval (2 seconds by default) share one network call. That is why it is safe to call useUser() in ten components instead of prop-drilling the result. It also explains a confusing case: a component mounting three seconds after another may trigger a second request, because the window has passed and SWR treats mount as a revalidation trigger (revalidateOnMount). If data is effectively static, useSWRImmutable (from swr/immutable) disables all automatic revalidation.
Focus Revalidation
const { data } = useSWR('/api/data', fetcher, {
// Revalidate when tab/window gains focus
revalidateOnFocus: true,
// Minimum interval between revalidations
focusThrottleInterval: 5000, // 5 seconds
});
Both values shown are the defaults, so this snippet changes nothing; it only makes the behavior visible. Focus revalidation surprises people in development, where switching from the editor to the browser counts as a focus event and triggers requests you did not expect to see in the network tab. focusThrottleInterval limits it to once per window, per key; if a view should never refetch on focus, set revalidateOnFocus: false for that hook instead of disabling it globally.
Frequently Asked Questions (FAQ)
Q. How do I stop SWR from fetching until I have the data it needs?
A. Pass null (or a function that returns null or throws) as the key, and SWR will not start a request. This is the conditional and dependent fetching pattern: for example, useSWR(user ? `/api/projects?uid=${user.id}` : null, fetcher) waits until user is loaded. It avoids requests with undefined in the URL, which otherwise return errors that get cached under that key.
Related Articles
- TanStack Query v5 in Practice
- Running a Blog on Astro + Cloudflare Pages: Trade-offs and How It Compares to Vercel
- Qwik and Resumability