Data Fetching with SWR: Revalidation, Mutations, Dependent Requests and Infinite Loading

Key takeaways

Hand-written useEffect fetching repeats loading and error state in every component and refetches data you already have. SWR shows cached data first and revalidates in the background; the post covers global config, mutate and optimistic UI, pagination, retries, prefetching and middleware.

Introduction

SWR is a React Hooks library for data fetching created by Vercel (the team behind Next.js). The name comes from stale-while-revalidate, an HTTP caching strategy that returns cached data immediately while fetching fresh data in the background.

Developed by Vercel’s Shu Ding (also creator of Nextra docs), SWR has become one of the most popular data fetching libraries in the React ecosystem, especially within the Next.js community.

The Problem

Traditional data fetching:

useEffect(() => {
  setLoading(true);
  fetch('/api/user')
    .then(res => res.json())
    .then(data => {
      setData(data);
      setLoading(false);
    });
}, []);

Problems:

  • Manual loading state
  • No caching
  • No revalidation
  • Boilerplate code

The list understates it. This effect has no error handling (a failed request leaves loading true forever), no cancellation (if the component unmounts or the URL changes, a late response still calls setData — the classic race where a slow response for page 1 overwrites the result for page 2), and no sharing: three components that need the current user make three identical requests. Each of those can be fixed by hand with an AbortController, an ignore flag, and a context, but you end up rebuilding a cache library badly in every project.

The Solution

With SWR:

const { data, error, isLoading } = useSWR('/api/user', fetcher);

The mental model is a global cache keyed by the first argument. The key ('/api/user') identifies the data; the fetcher is just the function SWR calls with that key when it needs fresh data. Every component that calls useSWR with the same key reads the same cache entry and is re-rendered when it changes, so “loading the user” becomes “subscribing to the user”. Keys can also be arrays — useSWR(['/api/user', token], ([url, token]) => fetchWithToken(url, token)) — and SWR hashes them stably, so the same array contents map to the same cache entry.

Where SWR Fits

The Next.js documentation has long used SWR in its client-side fetching examples, and it is common in Vercel’s ecosystem, but nothing about it is Next.js-specific: it works in any React app.

Why developers choose SWR:

  • Lightweight: 4KB gzipped (TanStack Query is ~12KB)
  • Built by Vercel: Guaranteed Next.js compatibility
  • Zero config: Works out of the box with sensible defaults
  • Real-time ready: Built-in support for polling and focus revalidation

SWR vs TanStack Query comparison:

FeatureSWRTanStack Query
Bundle size4KB12KB
Learning curveSimplerMore features, steeper
PaginationuseSWRInfiniteuseInfiniteQuery with more controls
DevtoolsCommunity extensionsOfficial devtools
Best forRead-heavy screens, simple mutationsComplex mutations and cache invalidation

When to use SWR:

  • Mostly reading data and keeping it fresh
  • Need a lightweight solution with a small API surface
  • Simple CRUD operations
  • Real-time features (focus tracking, polling)

When to use TanStack Query instead:

  • Many interdependent mutations that must invalidate groups of queries (query-key prefixes, invalidateQueries)
  • Want official dev tools for inspecting the cache
  • Need fine-grained control over garbage collection and stale times per query
  • Want the same library across React, Vue, Solid, and Svelte

The honest difference is less about features — both do caching, deduplication, pagination, infinite loading, and prefetching — and more about philosophy. SWR keeps the API tiny and treats “revalidate” as the answer to most questions. TanStack Query models queries and mutations as first-class objects with explicit lifecycles, which is more to learn but pays off when an app has many writes. See TanStack Query for that side.

Installation

npm install swr

Basic Usage

Simple Fetching

import useSWR from 'swr';

// Fetcher function
const fetcher = (url: string) => fetch(url).then(res => res.json());

function Profile() {
  const { data, error, isLoading } = useSWR('/api/user', fetcher);
  
  if (isLoading) return <div>Loading...</div>;
  if (error) return <div>Failed to load</div>;
  
  return <div>Hello, {data.name}!</div>;
}

This fetcher has a flaw that almost every SWR tutorial repeats: fetch does not reject on HTTP errors. A 404 or 500 response resolves normally, res.json() parses the error body (or throws a SyntaxError: Unexpected token '<' if the server returned an HTML error page), and SWR caches the error payload as if it were data. The error branch only runs for network failures. In real code, throw on non-2xx responses:

const fetcher = async (url: string) => {
  const res = await fetch(url);
  if (!res.ok) {
    const error = new Error(`Request failed: ${res.status}`) as Error & { status?: number };
    error.status = res.status;
    throw error;
  }
  return res.json();
};

The status property is what makes the retry logic under Error Handling work; without it, error.status === 404 is never true.

The difference between isLoading and isValidating also matters early. isLoading is true only when there is a request in flight and no cached data yet — the first load. isValidating is true for any request in flight, including background revalidations while stale data is displayed. Showing a full-page spinner on isValidating throws away the main benefit of SWR.

With TypeScript

interface User {
  id: number;
  name: string;
  email: string;
}

function Profile() {
  const { data, error, isLoading } = useSWR<User>(
    '/api/user',
    fetcher
  );
  
  return <div>{data?.name}</div>;
}

The generic only asserts the shape; SWR does not validate the response. If the API changes, TypeScript still believes data is a User. For data from outside your control, parse it in the fetcher (for example with Zod) so a contract change fails loudly in one place. Also note data is User | undefined: it is undefined during the first load and whenever the key is null, which is why the optional chaining is required.

Global Configuration

// app/providers.tsx (Next.js App Router) — must be a Client Component
'use client';
import { SWRConfig } from 'swr';

export function Providers({ children }: { children: React.ReactNode }) {
  return (
    <SWRConfig
      value={{
        fetcher: (url: string) => fetch(url).then(res => res.json()),
        revalidateOnFocus: false,
        revalidateOnReconnect: true,
      }}
    >
      {children}
    </SWRConfig>
  );
}

Then render <Providers>{children}</Providers> inside app/layout.tsx. The indirection is necessary: layout.tsx is a Server Component by default, and putting <SWRConfig value={{ fetcher: ... }}> there fails with Functions cannot be passed directly to Client Components unless you explicitly expose it by marking it with "use server", because a function cannot be serialized across the server/client boundary. A small 'use client' providers file is the standard pattern for every context-based library in the App Router.

Now you don’t need to pass fetcher every time:

const { data } = useSWR('/api/user'); // Fetcher is global

Configuration merges from the outside in: nested SWRConfig providers override their parents, and options passed to an individual useSWR call override both. That makes it practical to set conservative defaults globally (for example revalidateOnFocus: false for a dashboard with expensive queries) and turn behavior back on per hook where freshness matters.

Mutations

Basic Mutation

import useSWR, { mutate } from 'swr';

function UpdateProfile() {
  const { data } = useSWR('/api/user', fetcher);
  
  async function updateUser(newName: string) {
    // Update API
    await fetch('/api/user', {
      method: 'PATCH',
      body: JSON.stringify({ name: newName }),
    });
    
    // Revalidate
    mutate('/api/user');
  }
  
  return <button onClick={() => updateUser('New Name')}>Update</button>;
}

The global mutate(key) with no data argument means “this key is stale, refetch it”, and every mounted useSWR('/api/user') re-renders with the new result. It only affects the exact key: mutate('/api/user') does not touch '/api/user?include=teams'. To revalidate a family of keys, pass a filter function — mutate(key => typeof key === 'string' && key.startsWith('/api/user')). For write operations that should expose their own isMutating and error state, SWR 2 also provides useSWRMutation from swr/mutation, which is closer to what TanStack Query calls a mutation.

Optimistic Updates

import useSWR, { mutate } from 'swr';

function TodoList() {
  const { data: todos } = useSWR<Todo[]>('/api/todos', fetcher);
  
  async function addTodo(title: string) {
    const newTodo = { id: Date.now(), title, completed: false };
    
    // Optimistic update
    mutate(
      '/api/todos',
      async (currentTodos) => {
        // Update UI immediately
        const updatedTodos = [...(currentTodos || []), newTodo];
        
        // Send request
        await fetch('/api/todos', {
          method: 'POST',
          body: JSON.stringify(newTodo),
        });
        
        // Return optimistic data
        return updatedTodos;
      },
      {
        optimisticData: [...(todos || []), newTodo],
        rollbackOnError: true,
      }
    );
  }
  
  return (
    <div>
      {todos?.map(todo => <div key={todo.id}>{todo.title}</div>)}
    </div>
  );
}

Three things happen in order. optimisticData is written to the cache immediately, so the new todo appears before the network request starts. The async function then runs the real request and returns the value to store. If it throws, rollbackOnError: true restores the previous cache state. After that SWR revalidates the key by default, replacing the optimistic list with whatever the server actually has — which is why the fake id: Date.now() is tolerable here: it is overwritten by the real id a moment later.

A failure mode to watch for is two quick additions in a row. Each call computes its optimistic list from the todos value captured at render time, so the second optimistic update can briefly drop the first item until revalidation fixes it. Passing a function to optimisticData (optimisticData: current => [...(current ?? []), newTodo]) computes from the latest cache instead and avoids the flicker. Also note this example sends newTodo without a Content-Type: application/json header; many servers will then ignore the body.

Bound Mutate

import useSWR from 'swr';

function Profile() {
  const { data, mutate } = useSWR('/api/user', fetcher);
  
  async function updateUser() {
    // Update with bound mutate (no need to pass key)
    await mutate(async (user) => {
      await fetch('/api/user', { method: 'PATCH', body: JSON.stringify({ name: 'Updated' }) });
      return { ...user, name: 'Updated' };
    });
  }
  
  return <div>{data?.name}</div>;
}

Conditional Fetching

function UserProfile({ userId }: { userId?: number }) {
  // Only fetch if userId exists
  const { data } = useSWR(
    userId ? `/api/users/${userId}` : null,
    fetcher
  );
  
  return <div>{data?.name}</div>;
}

Dependent Requests

function UserPosts({ userId }: { userId: number }) {
  // First request
  const { data: user } = useSWR(`/api/users/${userId}`, fetcher);
  
  // Second request depends on first
  const { data: posts } = useSWR(
    user ? `/api/posts?userId=${user.id}` : null,
    fetcher
  );
  
  return <div>{posts?.length} posts</div>;
}

A null key means “don’t fetch”, so the second hook waits for the first without any explicit sequencing code. SWR also accepts a function as the key and treats a thrown error inside it as null, so () => `/api/posts?userId=${user.id}` works even while user is undefined. The trade-off is a waterfall: the posts request cannot start until the user response arrives, doubling latency. If the posts endpoint can take userId directly (as it can here, since userId is already a prop), fetch both in parallel and keep dependent keys for cases where the second request genuinely needs data from the first.

Pagination

function UserList() {
  const [page, setPage] = useState(1);
  const { data, error, isLoading } = useSWR(
    `/api/users?page=${page}&limit=20`,
    fetcher
  );
  
  return (
    <div>
      {data?.users.map((user: any) => (
        <div key={user.id}>{user.name}</div>
      ))}
      
      <button onClick={() => setPage(page - 1)} disabled={page === 1}>
        Previous
      </button>
      <button onClick={() => setPage(page + 1)}>Next</button>
    </div>
  );
}

Each page is a separate key, so going back to page 1 renders instantly from cache. The rough edge is moving forward to an uncached page: the key changes, data becomes undefined, and the list flashes empty while loading. Passing { keepPreviousData: true } keeps showing the previous page’s data until the new one arrives, which is usually what users expect from pagination controls. You can also render the next page’s hook in a hidden component to prefetch it.

Infinite Loading

import useSWRInfinite from 'swr/infinite';

function InfiniteList() {
  const getKey = (pageIndex: number, previousPageData: any) => {
    // Reached the end
    if (previousPageData && !previousPageData.hasMore) return null;
    
    // First page
    return `/api/users?page=${pageIndex + 1}&limit=20`;
  };
  
  const { data, size, setSize, isLoading } = useSWRInfinite(
    getKey,
    fetcher
  );
  
  const users = data ? data.flatMap(page => page.users) : [];
  const hasMore = data?.[data.length - 1]?.hasMore;
  
  return (
    <div>
      {users.map((user) => (
        <div key={user.id}>{user.name}</div>
      ))}
      
      {hasMore && (
        <button onClick={() => setSize(size + 1)}>Load More</button>
      )}
    </div>
  );
}

getKey is called for each page index with the previous page’s data, and returning null stops the chain. size is the number of pages SWR should load; setSize(size + 1) asks for one more. For cursor-based APIs, build the key from previousPageData.nextCursor instead of the page index — offset pagination shifts when items are inserted at the top, producing duplicates across pages. By default revalidating an infinite list refetches only the first page (revalidateFirstPage: true) and then compares; revalidateAll: true refetches every loaded page, which is correct but expensive for long lists. Mutating an infinite list is also more awkward than a single key: call the mutate returned by useSWRInfinite rather than the global one, because the internal cache key is not the string your getKey produced.

Real-time Updates

Auto Revalidation

const { data } = useSWR('/api/data', fetcher, {
  refreshInterval: 3000, // Refresh every 3 seconds
  refreshWhenHidden: false, // Pause when tab hidden
  refreshWhenOffline: false, // Pause when offline
});

Polling is the simplest form of “real-time” and often good enough for dashboards, but it scales with the number of open tabs, not with how often data changes. A three-second interval across a few thousand users is a steady request load on your API even when nothing happens. For data that changes rarely but must appear quickly, a push channel (WebSocket or Server-Sent Events) that calls mutate(key) on each event is cheaper; SWR 2 wraps that pattern in useSWRSubscription from swr/subscription.

Manual Revalidation

import { useSWRConfig } from 'swr';

function RefreshButton() {
  const { mutate } = useSWRConfig();
  
  return (
    <button onClick={() => mutate('/api/user')}>
      Refresh
    </button>
  );
}

Error Handling

Retry on Error

const { data, error } = useSWR('/api/user', fetcher, {
  onErrorRetry: (error, key, config, revalidate, { retryCount }) => {
    // Never retry on 404
    if (error.status === 404) return;
    
    // Only retry 3 times
    if (retryCount >= 3) return;
    
    // Retry after 5 seconds
    setTimeout(() => revalidate({ retryCount }), 5000);
  },
});

By default SWR retries failed requests with exponential backoff, which is sensible for flaky networks and wasteful for errors that will never succeed — 401, 403, 404, and validation errors. Overriding onErrorRetry as above is the fix, and it depends on the fetcher attaching status to the thrown error (see the fetcher under Basic Usage). A related surprise: after an error, SWR keeps the last successful data as well as setting error, so components that check data before error will keep showing stale content without any indication that refreshing failed.

Error Handling

const { data, error } = useSWR('/api/user', fetcher, {
  onError: (error, key) => {
    console.error('SWR error:', error);
    toast.error('Failed to fetch data');
  },
  onSuccess: (data, key, config) => {
    console.log('Data loaded:', data);
  },
});

These callbacks fire once per request, not once per component. When five components share a key and the request fails, deduplication means one request and one onError — but if each component passes its own onError, only the one attached to the hook that initiated the request runs. Put side effects like toasts in the global SWRConfig or a single custom hook, not in every consumer.

Prefetching

import { mutate } from 'swr';

function UserList({ users }: { users: User[] }) {
  const prefetch = (userId: number) => {
    // Prefetch user data
    mutate(
      `/api/users/${userId}`,
      fetch(`/api/users/${userId}`).then(res => res.json())
    );
  };
  
  return (
    <ul>
      {users.map((user) => (
        <li key={user.id} onMouseEnter={() => prefetch(user.id)}>
          <Link to={`/users/${user.id}`}>{user.name}</Link>
        </li>
      ))}
    </ul>
  );
}

Populating the cache with mutate(key, promise) works, but SWR 2 has a dedicated API for this: preload('/api/users/1', fetcher) from swr starts the request and lets a later useSWR with the same key reuse the in-flight promise. It can even be called outside React, for example in a router loader. Hover prefetching is a cheap win for detail pages, with one caution: debounce it or guard with a “already prefetched” set, or a user sweeping the mouse down a long list fires dozens of requests.

Middleware

import useSWR from 'swr';

// Logging middleware
function logger(useSWRNext: any) {
  return (key: any, fetcher: any, config: any) => {
    const swr = useSWRNext(key, fetcher, config);
    
    useEffect(() => {
      console.log('SWR Request:', key);
    }, [key]);
    
    return swr;
  };
}

// Use middleware
const { data } = useSWR('/api/user', fetcher, { use: [logger] });

Custom hooks, loading flags and clearing the cache

Keep each key in one custom hook

// hooks/useUser.ts
export function useUser(id: number) {
  return useSWR<User>(
    id ? `/api/users/${id}` : null,
    fetcher,
    {
      revalidateOnFocus: false,
      dedupingInterval: 2000,
    }
  );
}

// Usage
const { data: user } = useUser(123);

The reason to wrap useSWR is the key, not tidiness. Deduplication, mutate('/api/users/123') and fallback all match on the exact key, so /api/users/123 in one component and /api/users/123/ or /api/users/123?x= in another are separate cache entries that never see each other’s updates. With the key built in one function, every consumer and every mutation agree on it. Options set here, such as revalidateOnFocus: false, also apply to every consumer, so set them per resource rather than per component.

isLoading for the first load, isValidating for background refreshes

function Component() {
  const { data, error, isLoading, isValidating } = useSWR(key, fetcher);
  
  // First load
  if (isLoading) return <Skeleton />;
  
  // Error state
  if (error) return <ErrorMessage error={error} />;
  
  // Background revalidation indicator
  return (
    <div>
      {isValidating && <RefreshIcon className="animate-spin" />}
      {data && <DataDisplay data={data} />}
    </div>
  );
}

Clearing cached data

import { useSWRConfig } from 'swr';

function CacheManager() {
  const { cache, mutate } = useSWRConfig();
  
  const clearCache = () => {
    // Clear specific key
    mutate('/api/user', undefined, { revalidate: false });
    
    // Clear all cache
    if (cache instanceof Map) {
      cache.clear();
    }
  };
  
  return <button onClick={clearCache}>Clear Cache</button>;
}

Clearing the Map directly is the part to avoid in practice. It removes entries without notifying mounted hooks, so components keep rendering old data until something else triggers a revalidation. The supported way to reset everything — for example on logout, so the next user never sees the previous user’s data — is mutate(() => true, undefined, { revalidate: false }), which clears every key and updates subscribers. For per-user data, including the user id in the key is an additional safety net.

Next.js Integration

API Routes

// app/api/users/route.ts (Next.js 13+)
export async function GET() {
  const users = await prisma.user.findMany();
  return Response.json({ users });
}

Client Component

'use client';

import useSWR from 'swr';

export function UserList() {
  const { data } = useSWR('/api/users', fetcher);
  
  return (
    <ul>
      {data?.users.map((user: any) => (
        <li key={user.id}>{user.name}</li>
      ))}
    </ul>
  );
}

In the App Router, the first question is whether you need SWR for this data at all. A Server Component can await the database directly and send HTML, with no loading state and no API route. SWR earns its place for data that changes while the page is open, depends on client-only state (filters, the logged-in user’s session in the browser), or must refresh on focus. A common hybrid is to fetch on the server and hand the result to SWR as initial data — <SWRConfig value={{ fallback: { '/api/users': users } }}> — so the page renders immediately and SWR takes over revalidation on the client.

Deduplication and focus revalidation

Deduplication

// Multiple components can use the same key
// SWR makes only ONE request
function ComponentA() {
  const { data } = useSWR('/api/user', fetcher);
}

function ComponentB() {
  const { data } = useSWR('/api/user', fetcher); // Uses same cache
}

Deduplication is time-based: requests for the same key within dedupingInterval (2 seconds by default) share one network call. That is why it is safe to call useUser() in ten components instead of prop-drilling the result. It also explains a confusing case: a component mounting three seconds after another may trigger a second request, because the window has passed and SWR treats mount as a revalidation trigger (revalidateOnMount). If data is effectively static, useSWRImmutable (from swr/immutable) disables all automatic revalidation.

Focus Revalidation

const { data } = useSWR('/api/data', fetcher, {
  // Revalidate when tab/window gains focus
  revalidateOnFocus: true,
  
  // Minimum interval between revalidations
  focusThrottleInterval: 5000, // 5 seconds
});

Both values shown are the defaults, so this snippet changes nothing; it only makes the behavior visible. Focus revalidation surprises people in development, where switching from the editor to the browser counts as a focus event and triggers requests you did not expect to see in the network tab. focusThrottleInterval limits it to once per window, per key; if a view should never refetch on focus, set revalidateOnFocus: false for that hook instead of disabling it globally.


Frequently Asked Questions (FAQ)

Q. How do I stop SWR from fetching until I have the data it needs?

A. Pass null (or a function that returns null or throws) as the key, and SWR will not start a request. This is the conditional and dependent fetching pattern: for example, useSWR(user ? `/api/projects?uid=${user.id}` : null, fetcher) waits until user is loaded. It avoids requests with undefined in the URL, which otherwise return errors that get cached under that key.