Skip to content

Fix: SWR Not Working — Key Changes, Mutate Not Updating, Conditional Fetching, and SSR Hydration

FixDevs ·

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. Calling mutate() from the hook revalidates the current key. To revalidate a different key, you need the global mutate() from useSWRConfig and a key matcher.
  • Empty string is a valid key. "" (and 0, false) all trigger fetches. To skip, use null or undefined — falsy except those — as the key.
  • Hydration uses the cache. Without fallbackData or fallback, 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 | undefined

For 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:

  • data is undefined forever. Your fetcher threw without rejecting — check that thrown errors propagate. A sync throw in 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 localStorage provider via SWRConfig.provider.
  • isLoading is true even with cached data. isLoading is true only on the first load. If you want “background revalidation” state, use isValidating.
  • refreshInterval doesn’t fire when the tab is hidden. SWR pauses revalidation on hidden tabs by default. Set refreshWhenHidden: true if you need it (rarely the right call).
  • mutate resolves 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.
  • useSWRImmutable for never-changing data. It’s a convenience wrapper for useSWR with 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 — bump dedupingInterval if needed.
  • Server Component fetches conflict with client SWR. Don’t useSWR for data the server already passed via props unless you need client-side revalidation. If you do, hydrate with fallbackData.

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.

F

FixDevs

Solo developer based in Japan. Every solution is cross-referenced with official documentation and tested before publishing.

Was this article helpful?

Related Articles