SWR Patterns
Quick Guide: SWR renders the cached value immediately and revalidates behind it, so the cache key is the whole identity of a request and an unstable key is the single most expensive mistake here.
isLoadingcovers the first fetch only andisValidatingcovers every fetch, which is why using the second as a spinner hides the data SWR exists to show. Reads areuseSWR, writes areuseSWRMutation, and anullkey is how a request is skipped without breaking the rules of hooks.
Detailed Resources:
- examples/core.md — fetchers, key shapes, state handling,
SWRConfig - examples/mutations.md —
useSWRMutation, optimistic updates,populateCache, globalmutate - examples/caching.md — revalidation strategies, prefetching, cache persistence
- examples/pagination.md —
useSWRInfinite, infinite scroll, offset and filtered paging - examples/conditional.md — the null key, dependent queries, function keys
- examples/error-handling.md — retry policy, status-specific handling, offline
- examples/suspense.md — suspense mode and server-rendered fallback data
- reference.md — every config option with its default, and the hook return shapes
Which path applies
- Reading a resource —
useSWR, which fetches on mount and keeps the value fresh. Patterns 1–3. - Writing —
useSWRMutation, which does nothing untiltrigger()is called. Pattern 4. - A list that grows —
useSWRInfinite, whosegetKey(pageIndex, previousPage)both builds each page's key and signals the end by returningnull. Pattern 7. - Suspense instead of loading branches —
suspense: truemakes the component suspend anddatanon-null; see examples/suspense.md. Everything else here still applies.
<critical_requirements>
Before writing SWR code
Give each request a key that is stable across renders — a string, or an array of primitives. The key is the cache identity and the dependency: an object or array literal is a new reference every render, so SWR sees a new key, fetches again, re-renders, and repeats.
Throw from the fetcher on a non-OK response. SWR's error state is driven by a rejected promise,
so a fetcher that returns res.json() unconditionally hands the error body over as data and no
error branch ever runs.
Branch on isLoading for the first fetch and isValidating for a refresh in progress.
isLoading is true only when there is no data yet, which is exactly when a skeleton is right;
isValidating is true during background revalidation, when there is data on screen to keep.
Reach for useSWRMutation for anything that writes. useSWR fires on mount, so a POST written
as a useSWR fetcher sends itself as soon as the component renders.
</critical_requirements>
Auto-detection: useSWR, useSWRMutation, useSWRInfinite, useSWRImmutable, SWRConfig,
useSWRConfig, mutate, trigger, isValidating, revalidateOnFocus, dedupingInterval,
keepPreviousData, fallbackData, optimisticData, rollbackOnError, populateCache, preload,
swr/mutation, swr/infinite, swr/immutable
Applies to:
- Cache keys, fetchers and the shape of what a hook returns
- Revalidation policy: focus, reconnect, interval, stale, and disabling all of it
- Writes, optimistic updates and cache invalidation after them
- Cursor and offset pagination that accumulates pages
- Conditional and dependent fetching
- Retry policy and hydrating from server-rendered data
Handled elsewhere:
- Client state that never came from a server — this skill caches responses
- The transport itself; a fetcher is any function returning a promise, and what it uses is open
- How an error boundary is built — this skill settles which option throws an error into one
- APIs addressed through a graph query language, whose clients cache normalised entities rather than whole responses under a key
<philosophy>
Philosophy
The name is the algorithm: return what is cached, revalidate behind it, re-render if the answer changed. A component therefore has data at nearly every moment of its life, and the interesting states are not "loading or loaded" but "is this being checked" and "is this out of date".
Everything else follows. The key is a global identity, so two components asking for the same key share one request and one cache entry with no coordination between them. Revalidation is triggered by events the user causes — refocusing the tab, reconnecting — rather than by timers, because those are the moments the data on screen is most likely to be stale.
</philosophy><patterns>
Core patterns
Pattern 1: The fetcher
interface FetchError extends Error {
info: unknown;
status: number;
}
const fetcher = async <T>(url: string): Promise<T> => {
const response = await fetch(url);
if (!response.ok) {
const error = new Error("Fetch failed") as FetchError;
error.info = await response.json().catch(() => null);
error.status = response.status;
throw error;
}
return response.json();
};
Attaching status is what lets a component tell a 404 from a 500, and lets a retry policy decline
to retry either. Define the fetcher at module scope — one created inside a component is a new
reference on every render.
Full code: examples/core.md — client-based and multi-argument fetchers
Pattern 2: isLoading vs isValidating
// data: undefined, isLoading: true, isValidating: true — first fetch
// data: T, isLoading: false, isValidating: false — settled
// data: T, isLoading: false, isValidating: true — revalidating behind the value
// error: Error, isLoading: false, isValidating: false — failed with nothing cached
// data: T, error: Error, isLoading: false — failed with a cached value to show
if (isLoading) return <Skeleton />;
return (
<div>
{isValidating && <RefreshIndicator />}
{error && data && <Banner>Showing cached data; the last refresh failed</Banner>}
<Content data={data} />
</div>
);
The last row is the one to design for: a failed refresh over good data is a banner, not an error page.
Full code: examples/core.md
Pattern 3: Global configuration
const ERROR_RETRY_COUNT = 3;
const DEDUP_INTERVAL_MS = 2000;
<SWRConfig
value={{
fetcher,
errorRetryCount: ERROR_RETRY_COUNT,
dedupingInterval: DEDUP_INTERVAL_MS,
keepPreviousData: true,
fallback, // keys pre-filled from a server render
}}
>
{children}
</SWRConfig>;
A nested SWRConfig overrides its parent, which is how one static section opts out of revalidation
without every hook in it repeating the options. Every option and its default is in
reference.md.
Full code: examples/core.md
Pattern 4: Writes
import useSWRMutation from "swr/mutation";
async function createPost(
url: string,
{ arg }: { arg: CreatePostInput },
): Promise<Post> {
const response = await fetch(url, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(arg),
});
if (!response.ok) throw new Error("Failed to create post");
return response.json();
}
const { trigger, isMutating, error, reset } = useSWRMutation(
"/api/posts",
createPost,
);
await trigger({ title, content });
The mutation fetcher's second parameter is { arg } — whatever trigger() was called with.
trigger returns a promise, so the success path stays in the handler that called it.
Full code: examples/mutations.md
Pattern 5: Optimistic updates
const { trigger } = useSWRMutation(`/api/todos/${todo.id}`, toggleTodo, {
optimisticData: (current: Todo) => ({
...current,
completed: !current.completed,
}),
rollbackOnError: true,
revalidate: true,
});
optimisticData writes to the cache before the request leaves; rollbackOnError puts the previous
value back when it fails. Where the response already contains the new state, populateCache writes
it directly and revalidate: false skips the confirming round trip.
Full code: examples/mutations.md
Pattern 6: Conditional fetching with a null key
// null key: the hook runs, the request does not
const { data } = useSWR(userId ? `/api/users/${userId}` : null, fetcher);
// dependent: the second key does not exist until the first resolves
const { data: user } = useSWR(`/api/users/${userId}`, fetcher);
const { data: posts } = useSWR(
user ? `/api/users/${user.id}/posts` : null,
fetcher,
);
A key can also be a function returning null, which suits several conditions at once. What it must
never be is a conditionally called hook.
Full code: examples/conditional.md
Pattern 7: Pagination
import useSWRInfinite from "swr/infinite";
const getKey = (pageIndex: number, previousPageData: PostsResponse | null) => {
if (previousPageData && !previousPageData.hasMore) return null; // stop
if (pageIndex === 0) return `/api/posts?limit=${PAGE_SIZE}`;
return `/api/posts?limit=${PAGE_SIZE}&cursor=${previousPageData?.nextCursor}`;
};
const { data, size, setSize } = useSWRInfinite<PostsResponse>(getKey, fetcher, {
revalidateFirstPage: false,
});
const posts = data?.flatMap((page) => page.posts) ?? [];
data is an array of pages, so flatMap rather than map. getKey returning null is the only
thing that ends the list — without it setSize keeps requesting.
Full code: examples/pagination.md
Pattern 8: Revalidation strategy
const POLL_INTERVAL_MS = 10 * 1000;
// live: poll, but not into a hidden tab
useSWR(key, fetcher, {
refreshInterval: POLL_INTERVAL_MS,
refreshWhenHidden: false,
});
// default: on focus and on reconnect, both already true
useSWR(key, fetcher);
// static: fetch once and leave it
import useSWRImmutable from "swr/immutable";
useSWRImmutable(key, fetcher);
useSWRImmutable is the three revalidation options turned off, under one name.
Full code: examples/caching.md — plus preload() and cache persistence
<red_flags>
Red flags
Breaks at runtime:
- An object or array literal as the key — a new reference each render means a new key each render, and the fetch loop never settles.
- A fetcher that does not throw — the error body is stored as
data,errorstays undefined, and the failure renders as content. useSWRused for a write — it fires on mount, so the request is sent before any user acts.- A hook called inside a condition — use a
nullkey instead; the hook must run every render. suspense: truewith noSuspenseboundary above it — the thrown promise reaches the error boundary or the top of the tree.optimisticDatawithoutrollbackOnError— a failed write leaves the invented value in the cache until something else revalidates.
Surprising behaviour:
nullskips the request butundefineddoes not — it is interpolated, and the request goes to/api/users/undefined.- A bound
mutate()revalidates its own key; the globalmutate()with no filter revalidates the entire cache. revalidateOnFocusfires on every tab focus regardless of freshness —focusThrottleIntervalbounds it.keepPreviousData: trueon a search box shows the previous query's results under the new query.fallbackkeys are matched exactly, so/api/users/1and/api/users/1/hydrate separately.revalidateAll: trueonuseSWRInfiniterefetches every loaded page on each revalidation.refreshInterval: 0disables polling, which is what omitting it does.- Default retry does not know which failures are worth retrying — it will retry a 404 and a 401
unless
onErrorRetrysays otherwise. Errorobjects do not survive JSON serialization, so a persisted cache needs a structured error shape.- Nothing cancels an in-flight request. A response that is no longer wanted is discarded rather than
aborted — a fetcher that must actually stop work in flight has to carry its own
AbortController.
</red_flags>
Scan to join WeChat group