SWR
SWR is a tiny data-fetching library from Vercel, named after the HTTP caching directive stale-while-revalidate. The idea is simple but powerful: return cached (stale) data immediately so the UI is instant, then quietly refetch in the background and update the screen if anything changed. With a single hook you get caching, deduplication, automatic revalidation, and a great loading experience — all in roughly 4 KB.
Installing and the useSWR hook
Add SWR to a Vite + React project:
npm install swr
The core API is one hook, useSWR(key, fetcher). The key is a unique, stable identifier for the request (usually the URL). The fetcher is any async function that takes the key and returns data.
import useSWR from "swr";
const fetcher = (url) => fetch(url).then((res) => res.json());
function Profile({ userId }) {
const { data, error, isLoading } = useSWR(
`https://api.github.com/users/${userId}`,
fetcher
);
if (isLoading) return <p>Loading…</p>;
if (error) return <p>Failed to load profile.</p>;
return (
<article>
<h2>{data.name}</h2>
<p>{data.bio}</p>
<span>{data.public_repos} public repos</span>
</article>
);
}
SWR returns a small object. The most useful fields:
| Field | Meaning |
|---|---|
data | Resolved value from the fetcher (undefined until loaded) |
error | Thrown error from the fetcher, if any |
isLoading | true only on the first load with no cached data |
isValidating | true whenever a request is in flight (initial or revalidation) |
mutate | Function to update or revalidate this key locally |
The fetcher is not tied to
fetch. Use Axios, a GraphQL client, or anything that returns a promise. SWR only cares about the key and the resolved value.
The stale-while-revalidate strategy
When two components ask for the same key, SWR deduplicates the request — only one network call fires, and both components share the cached result. The first render after a cache hit shows stale data instantly (no spinner), while isValidating flips to true as SWR confirms the data is fresh in the background. If the refetched value differs, the component re-renders with the update; if not, nothing flickers.
This is why a page navigated to a second time feels instant: the cache serves immediately and revalidation happens silently.
Revalidation triggers
SWR automatically revalidates on several events, all configurable:
const { data } = useSWR("/api/dashboard", fetcher, {
revalidateOnFocus: true, // refetch when the tab regains focus
revalidateOnReconnect: true, // refetch when the network comes back
refreshInterval: 5000, // poll every 5 seconds
dedupingInterval: 2000, // ignore duplicate calls within 2s
});
| Option | Default | Effect |
|---|---|---|
revalidateOnFocus | true | Refetch when the window is refocused |
revalidateOnReconnect | true | Refetch after the browser reconnects |
refreshInterval | 0 | Poll on an interval (ms); 0 disables polling |
dedupingInterval | 2000 | Window during which identical requests are merged |
keepPreviousData | false | Keep old data visible while a new key loads |
Setting the key to null (or returning null from a function) skips the request entirely — handy for dependent or conditional fetching:
function Repos({ user }) {
// Wait until `user` exists before fetching repos.
const { data } = useSWR(user ? `/api/users/${user.id}/repos` : null, fetcher);
return <ul>{data?.map((r) => <li key={r.id}>{r.name}</li>)}</ul>;
}
Mutating data with mutate
mutate lets you update the cache directly — essential after a write. You can use the bound mutate returned by the hook, or the global mutate to target any key.
import useSWR, { useSWRConfig } from "swr";
function TodoList() {
const { data: todos } = useSWR("/api/todos", fetcher);
const { mutate } = useSWRConfig();
async function addTodo(title) {
const newTodo = { id: crypto.randomUUID(), title, done: false };
// Optimistic update: show it immediately, then reconcile.
await mutate(
"/api/todos",
async (current) => {
await fetch("/api/todos", {
method: "POST",
body: JSON.stringify(newTodo),
});
return [...current, newTodo];
},
{ optimisticData: [...(todos ?? []), newTodo], rollbackOnError: true }
);
}
return (
<>
<button onClick={() => addTodo("Write docs")}>Add</button>
<ul>{todos?.map((t) => <li key={t.id}>{t.title}</li>)}</ul>
</>
);
}
With optimisticData the UI updates before the request resolves; rollbackOnError restores the previous value if the POST fails. Calling mutate("/api/todos") with no data argument simply triggers a revalidation of that key.
Output:
Render 1: Loading… (no cache)
Render 2: [Write docs] (optimistic, request in flight)
Render 3: [Write docs] (server confirmed, cache reconciled)
SWR vs TanStack Query
Both solve server-state caching, but they make different trade-offs.
| Aspect | SWR | TanStack Query |
|---|---|---|
| Bundle size | ~4 KB, minimal API | Larger, feature-rich |
| Mental model | Keys + fetcher | Query keys + query/mutation functions |
| Mutations | mutate (manual cache control) | Dedicated useMutation with lifecycle hooks |
| Devtools | Basic (community) | First-class devtools |
| Pagination/infinite | useSWRInfinite | Built-in useInfiniteQuery |
| Best for | Lightweight apps, simple read-heavy UIs | Complex caching, heavy mutation workflows |
Reach for SWR when you want the smallest footprint and mostly read data; reach for TanStack Query when you need granular cache control, structured mutations, and richer tooling.
Best Practices
- Use stable, unique keys — the URL with its query params — so the cache and deduplication behave correctly.
- Define your fetcher once and pass it via
SWRConfigto avoid repeating it on every hook. - Prefer
isLoading(first load only) overisValidating(any in-flight request) when deciding whether to show a spinner. - Use
optimisticDatawithrollbackOnErrorfor snappy writes that stay consistent on failure. - Gate dependent requests by passing
nullas the key instead of branching around the hook, which would violate the Rules of Hooks. - Tune
refreshIntervalandrevalidateOnFocusper resource; aggressive polling on rarely-changing data wastes requests.