Fix: SWR Not Working — Key Changes, Mutate Not Updating, Conditional Fetching, and SSR Hydration
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.
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).
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.
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 '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.
Fix: nuqs Not Working — URL State Not Syncing, Type Errors, or Server Component Issues
How to fix nuqs URL search params state management — useQueryState and useQueryStates setup, parsers, server-side access, shallow routing, history mode, and Next.js App Router integration.