Fix: SWR Not Working — Key Changes, Mutate Not Updating, Conditional Fetching, and SSR Hydration
Part of: React & Frontend Errors
Quick Answer
How to fix SWR errors — useSWR not refetching on key change, mutate not invalidating, conditional null key, fallbackData vs fallback, SSR hydration mismatch, infinite scroll pagination, and TypeScript typing.
The Error
You change the URL passed to useSWR but the response stays stale:
const { data } = useSWR(`/api/posts?page=${page}`, fetcher);
// page changes from 1 → 2. data still shows page 1 results.Or mutate() after a successful POST doesn’t refresh the list:
const { data, mutate } = useSWR("/api/posts", fetcher);
async function addPost() {
await fetch("/api/posts", { method: "POST", body });
mutate(); // List doesn't update.
}Or you try to skip the request when the user isn’t loaded and SWR fetches anyway:
const { data } = useSWR(user ? `/api/users/${user.id}/posts` : "", fetcher);
// Fires a request to "" — 404.Or you SSR a page with SWR and get hydration warnings:
Warning: Text content did not match. Server: "Loading..." Client: "5 posts"Why This Happens
SWR’s contract is built around the key — a string (or array, or function) that uniquely identifies a piece of data. Almost every issue traces to one of these:
- Key changes trigger a new fetch. SWR keeps separate cache entries per key. A new key value means new fetch, new cache slot. If your key isn’t changing the way you think, neither is the data.
mutate()is keyed. Callingmutate()from the hook revalidates the current key. To revalidate a different key, you need the globalmutate()fromuseSWRConfigand a key matcher.- Empty string is a valid key.
""(and0,false) all trigger fetches. To skip, usenullorundefined— falsy except those — as the key. - Hydration uses the cache. Without
fallbackDataorfallback, the server renders the loading state and the client renders the fetched state, which don’t match.
The reason these issues feel like “SWR is broken” is that SWR makes a deliberate trade-off: it gives you a single hook with five lines of code that handles caching, dedup, revalidation, and focus-refresh — but the contract assumes you treat the key as a primary key for a cache row. Most “stale data” reports come from treating the URL as decoration (“the URL is in the fetcher, so why does the key not matter?”) rather than the cache identity. The day you internalize that the key is the cache key, the API becomes predictable.
SWR also bakes in opinions about background revalidation that surprise teams arriving from TanStack Query, RTK Query, or plain useEffect + fetch. By default, SWR revalidates on focus, on reconnect, and on a configurable interval. It also deduplicates concurrent fetches of the same key within a 2-second window. If you see “my fetch fired three times when the tab regained focus,” that is revalidateOnFocus and revalidateIfStale both firing. If you see “my fetch fired only once when I expected two,” that is the dedup window. Both are toggles on <SWRConfig>, but you have to know they exist to turn them off.
Fix 1: Make the Key Reflect Every Variable
The key must capture every input that should trigger a refetch:
// Wrong — `userId` change doesn't refetch:
const { data } = useSWR("/api/posts", () => fetcher(`/api/posts?user=${userId}`));
// Right — key includes the variable:
const { data } = useSWR(`/api/posts?user=${userId}`, fetcher);For multi-parameter requests, use an array key:
const { data } = useSWR(
["/api/posts", userId, page],
([url, user, p]) => fetch(`${url}?user=${user}&page=${p}`).then((r) => r.json()),
);Array keys are compared deeply, so [/api/posts, 5, 1] and [/api/posts, 5, 1] hit the same cache entry across renders.
Pro Tip: Make the key a function when you want lazy evaluation. The function returns the key (or null to skip):
const { data } = useSWR(
() => (userId ? ["/api/posts", userId] : null),
([url, id]) => fetch(`${url}?user=${id}`).then((r) => r.json()),
);This avoids the "" or null/undefined ambiguity — if userId is falsy, the function returns null and SWR skips.
Fix 2: Skip Fetches With null Keys
To skip a fetch conditionally, return null from a function key:
const { data } = useSWR(
user ? `/api/users/${user.id}/posts` : null,
fetcher,
);Don’t pass "" (empty string) — SWR will fetch it. null, undefined, or a key function returning one of those are the correct skip signals.
Common Mistake: Building keys with template literals when one variable is missing:
// If user is undefined, key becomes "/api/users/undefined/posts" — a valid key that fetches:
const { data } = useSWR(`/api/users/${user?.id}/posts`, fetcher);
// Fix:
const { data } = useSWR(
user?.id ? `/api/users/${user.id}/posts` : null,
fetcher,
);Fix 3: Mutate Correctly After Writes
mutate() from the hook revalidates the current key:
const { data, mutate } = useSWR("/api/posts", fetcher);
async function add(post) {
await fetch("/api/posts", { method: "POST", body: JSON.stringify(post) });
await mutate(); // Refetches "/api/posts".
}For optimistic updates — show the change instantly, then revalidate:
async function add(post) {
await mutate(
(current) => [...(current ?? []), post], // Optimistic data
{
revalidate: true, // Refetch from server after optimistic update
rollbackOnError: true,
populateCache: true,
},
);
await fetch("/api/posts", { method: "POST", body: JSON.stringify(post) });
}To mutate a different key from a component that doesn’t subscribe to it, use the global mutate:
import { useSWRConfig } from "swr";
function CreateButton() {
const { mutate } = useSWRConfig();
async function handle() {
await fetch("/api/posts", { method: "POST" });
mutate("/api/posts"); // Revalidate by exact key
mutate((key) => typeof key === "string" && key.startsWith("/api/posts")); // By matcher
}
}The matcher form is powerful for invalidating “all post-related” keys after a mutation.
Fix 4: SSR With fallbackData
To avoid hydration mismatches in SSR, pre-fill SWR’s cache with the server-rendered data:
// app/posts/page.tsx (Next.js App Router)
import PostsClient from "./posts-client";
export default async function Page() {
const posts = await fetch("https://api.example.com/posts").then((r) => r.json());
return <PostsClient initialPosts={posts} />;
}// posts-client.tsx
"use client";
import useSWR from "swr";
export default function PostsClient({ initialPosts }) {
const { data } = useSWR("/api/posts", fetcher, {
fallbackData: initialPosts,
});
return <List items={data} />;
}fallbackData makes the first render use initialPosts while the client revalidates in the background. The server and client both render the same initial markup — no hydration warning.
For multiple keys with their own initial data, use the global fallback map at the provider level:
// app/layout.tsx
import { SWRConfig } from "swr";
export default function Layout({ children }) {
return (
<SWRConfig
value={{
fallback: {
"/api/posts": [],
"/api/user": null,
},
}}
>
{children}
</SWRConfig>
);
}Note: fallbackData (single key, on the hook) and fallback (key map, on the provider) are different APIs. Use whichever fits — they don’t combine.
Fix 5: Conditional Fetching With Dependent Requests
When request B depends on request A’s data, key B with A’s result:
const { data: user } = useSWR("/api/me", fetcher);
const { data: posts } = useSWR(
() => `/api/users/${user.id}/posts`, // Throws until user.id exists.
fetcher,
);The function form throws (silently caught by SWR) until user.id is available, at which point B starts. This is SWR’s idiomatic dependent-fetch pattern.
For more explicit control:
const { data: posts } = useSWR(
user?.id ? `/api/users/${user.id}/posts` : null,
fetcher,
);Both work — the function form has the advantage of safely throwing on the optional chain.
Fix 6: Pagination and Infinite Scroll
useSWRInfinite handles pagination:
import useSWRInfinite from "swr/infinite";
const getKey = (pageIndex, previousPageData) => {
if (previousPageData && !previousPageData.length) return null; // Reached end
return `/api/posts?page=${pageIndex + 1}&limit=20`;
};
const { data, size, setSize } = useSWRInfinite(getKey, fetcher);
const posts = data?.flat() ?? [];
const isLoadingMore = size > 0 && data && typeof data[size - 1] === "undefined";
return (
<>
{posts.map(...)}
<button onClick={() => setSize(size + 1)} disabled={isLoadingMore}>
Load more
</button>
</>
);getKey returns the key for each page. Returning null from getKey signals “no more pages.”
Common Mistake: Forgetting to return null at the end. useSWRInfinite keeps loading empty pages forever, hitting your API on every “Load more” click.
Fix 7: TypeScript Typing
The hook is generic over the data and error types:
import useSWR, { type Fetcher } from "swr";
type Post = { id: number; title: string };
const fetcher: Fetcher<Post[], string> = (url) =>
fetch(url).then((r) => r.json());
const { data, error, isLoading } = useSWR<Post[], Error>("/api/posts", fetcher);
// data: Post[] | undefined
// error: Error | undefinedFor tuple keys, type the fetcher accordingly:
const fetcher: Fetcher<Post[], [string, number]> = ([url, userId]) =>
fetch(`${url}?user=${userId}`).then((r) => r.json());
const { data } = useSWR(["/api/posts", userId], fetcher);Pro Tip: Wrap useSWR in a typed helper for each endpoint:
function usePosts(userId?: number) {
return useSWR<Post[], Error>(
userId ? ["/api/posts", userId] : null,
fetcher,
);
}Cleaner call sites, consistent types, and easier to refactor.
Fix 8: Custom Fetcher and Error Handling
The default fetcher is a function you write. Make it surface HTTP errors as exceptions:
const fetcher = async (url: string) => {
const res = await fetch(url);
if (!res.ok) {
const error = new Error("Request failed") as Error & { status?: number; info?: unknown };
error.status = res.status;
error.info = await res.json().catch(() => undefined);
throw error;
}
return res.json();
};Then handle in components:
const { data, error } = useSWR("/api/posts", fetcher);
if (error?.status === 401) return <SignIn />;
if (error) return <ErrorMessage info={error.info} />;
if (!data) return <Loading />;
return <List items={data} />;For exponential backoff on transient errors:
<SWRConfig
value={{
onErrorRetry: (error, key, config, revalidate, { retryCount }) => {
if (error.status === 404) return;
if (retryCount >= 3) return;
setTimeout(() => revalidate({ retryCount }), 2 ** retryCount * 1000);
},
}}
>
{children}
</SWRConfig>This retries up to 3 times with 1s, 2s, 4s backoff, skipping 404s (no point retrying a missing resource).
Version History: How SWR Got Here
SWR has been remarkably API-stable across major versions — code written for 1.0 still runs on 2.x — but the internals and defaults have changed enough that copy-pasting from old blog posts can produce subtle bugs.
- SWR 0.x (2019–2020). First public releases from Vercel. The hook was simpler (
{ data, error }only — noisLoading, noisValidating). Most “I copied this code from Stack Overflow and it does not type-check” stories trace back to this era. - SWR 1.0 (Dec 2021). First stable major. Added the
<SWRConfig>provider as the recommended customization point, stabilizedmutatesemantics, and introduceduseSWRConfigfor cross-component cache access. Thefallbackmap andfallbackDatadistinction was finalized here. - SWR 2.0 (Jan 2023). Major rewrite of the mutation system.
mutatecalls now return a stable promise that resolves with the revalidated data.populateCacheandrollbackOnErrorbecame first-class. Optimistic updates withoutmutate({ populateCache: true })silently dropped on older versions — 2.0 made the behavior consistent. - SWR 2.1 (mid-2023). Added
keepPreviousData, which fixes the “list flashes empty during pagination key change” problem. If a tutorial works around this withuseEffectto stash the previous data, it predates 2.1. - SWR 2.2 (Aug 2023).
preload(key, fetcher)exported so you can warm the cache before a component mounts. Pairs well with React’s<Link>hover prefetch. - SWR 2.x maintenance. Continued improvements to TypeScript inference for tuple keys, better defaults for Next.js App Router (so
fallbackfrom server components hydrates correctly), and tighter compatibility with React Server Components. - SWR vs TanStack Query history. The two libraries diverged after SWR 1.0. SWR keeps a small surface area (
useSWR,useSWRInfinite,useSWRSubscription,mutate). TanStack Query (formerly React Query) ships a much larger API (useQuery,useInfiniteQuery,useMutation,useQueryClient, query keys with hierarchical invalidation). Pick SWR when you want the minimum viable client cache and TanStack Query when you need fine-grained control over invalidation graphs and devtools.
A few practical version notes: if you see mutate calls without await in old code, those are likely fire-and-forget patterns from pre-2.0 that worked because the promise was thrown away. They still run on 2.x but no longer guarantee what they did before. And if you have a hand-written localStorage cache provider from before SWR 1.0, the provider API has changed — the modern signature uses a Map-like interface, not the older object protocol.
Still Not Working?
A few less-obvious failures:
dataisundefinedforever. Your fetcher threw without rejecting — check that thrown errors propagate. A syncthrowin async code becomes a rejected promise;setTimeout(() => { throw ... })does not.- Cache doesn’t survive page reloads. SWR’s cache is in-memory. For persistence across reloads, configure
localStorageprovider viaSWRConfig.provider. isLoadingistrueeven with cached data.isLoadingistrueonly on the first load. If you want “background revalidation” state, useisValidating.refreshIntervaldoesn’t fire when the tab is hidden. SWR pauses revalidation on hidden tabs by default. SetrefreshWhenHidden: trueif you need it (rarely the right call).mutateresolves before the network is done. It returns the optimistic data immediately. To wait for the network,await mutate(...)— though for fire-and-forget mutations that’s overkill.useSWRImmutablefor never-changing data. It’s a convenience wrapper foruseSWRwith revalidation disabled. Good for things like static config endpoints.- Two components fetch the same key simultaneously. SWR deduplicates within
dedupingInterval(2s default). If you see two requests, they’re separated by more than that — bumpdedupingIntervalif needed. - Server Component fetches conflict with client SWR. Don’t
useSWRfor data the server already passed via props unless you need client-side revalidation. If you do, hydrate withfallbackData. - Pagination shows a brief empty list between pages. That is the cache slot for the new key being empty on first render. Pass
keepPreviousData: true(SWR 2.1+) and the previous page renders until the new page resolves. - Tab focus floods the network.
revalidateOnFocusdefaults totrue, and if you also have manyuseSWRhooks on the page, every one of them fetches on focus. SetrevalidateOnFocus: falseglobally and re-enable per-hook where it matters. - Auth-protected fetches retry forever after sign-out. Default
onErrorRetryretries on every status. Short-circuit on401/403:if (error.status === 401 || error.status === 403) return;inside youronErrorRetry.
For related React data fetching and state issues, see TanStack Query not working, React useEffect runs twice, React usestate not updating, and Next.js app router fetch cache.
Solo developer based in Japan. Every solution is cross-referenced with official documentation and tested before publishing.
Was this article helpful?
Related Articles
Fix: Next.js 15 cookies() Should Be Awaited — Route Used cookies, Cannot Modify Errors, and Library Mismatch
Fix Next.js 15 async cookies() and headers() errors — 'Route used cookies', 'Cookies can only be modified in a Server Action or Route Handler', codemod misses, library compatibility, and TypeScript type mismatches after upgrade.
Fix: Next.js 'params should be awaited before using its properties'
How to fix Next.js 15 async params and searchParams errors — await in Server Components, React.use in Client Components, generateMetadata, generateStaticParams, and the codemod migration path.
Fix: Clerk Not Working — Auth Not Loading, Middleware Blocking, or User Data Missing
How to fix Clerk authentication issues — ClerkProvider setup, middleware configuration, useUser and useAuth hooks, server-side auth, webhook handling, and organization features.
Fix: next-safe-action Not Working — Action Not Executing, Validation Errors Missing, or Type Errors
How to fix next-safe-action issues — action client setup, Zod schema validation, useAction and useOptimisticAction hooks, middleware, error handling, and authorization patterns.