React SWR: Stale-While-Revalidate Data Fetching (2026)

SWR is Vercel's lightweight data-fetching library for React, built around the HTTP cache-control stale-while-revalidate strategy: return cached (stale) data immediately, then revalidate in the background. It's smaller than TanStack Query and pairs naturally with Next.js, making it the go-to choice for Next.js projects that need client-side data fetching without the full TanStack Query feature set.

useSWR Basics

npm install swr
import useSWR from 'swr'

// Fetcher function — SWR calls this with the key
const fetcher = (url: string) => fetch(url).then(r => r.json())

function UserProfile({ id }) {
  const { data, error, isLoading, isValidating } = useSWR(
    `/api/users/${id}`,  // Key — also the URL
    fetcher
  )

  if (isLoading) return <Skeleton />
  if (error) return <p>Error: {error.message}</p>

  return (
    <div>
      {isValidating && <span>Refreshing...</span>}
      <h1>{data.name}</h1>
      <p>{data.email}</p>
    </div>
  )
}

SWR automatically revalidates on:

  • Window focus (user returns to the tab)
  • Network reconnect
  • Component remount
  • Interval (if refreshInterval is set)

Global Configuration

// app/providers.tsx or _app.tsx
import { SWRConfig } from 'swr'

const globalFetcher = async (url: string) => {
  const res = await fetch(url)
  if (!res.ok) {
    const error = new Error('Fetch failed') as any
    error.status = res.status
    error.info = await res.json()
    throw error
  }
  return res.json()
}

export function SWRProvider({ children }) {
  return (
    <SWRConfig
      value={{
        fetcher: globalFetcher,
        revalidateOnFocus: true,
        revalidateOnReconnect: true,
        revalidateIfStale: true,
        dedupingInterval: 2000,      // Dedupe requests within 2s
        errorRetryCount: 3,
        errorRetryInterval: 5000,
        onError: (error) => {
          if (error.status !== 403 && error.status !== 404) {
            reportError(error)
          }
        },
      }}
    >
      {children}
    </SWRConfig>
  )
}

Conditional Fetching

// Pass null key to disable fetching
function UserPosts({ userId }) {
  // Only fetch if userId exists
  const { data } = useSWR(userId ? `/api/users/${userId}/posts` : null, fetcher)

  return <ul>{data?.map(p => <li key={p.id}>{p.title}</li>)}</ul>
}

// Dependent fetching — fetch user first, then their projects
function UserProjects({ email }) {
  const { data: user } = useSWR(`/api/users?email=${email}`, fetcher)
  // Only runs when user.id is available
  const { data: projects } = useSWR(user?.id ? `/api/projects?userId=${user.id}` : null, fetcher)

  return <ProjectList projects={projects} />
}

// Complex key — any JSON-serializable value works as a key
const { data } = useSWR(
  { url: '/api/products', params: { category, page, sort } },
  ({ url, params }) => fetch(url + '?' + new URLSearchParams(params)).then(r => r.json())
)

Mutation and Revalidation

import useSWR, { useSWRConfig } from 'swr'

function DeleteButton({ postId }) {
  const { mutate } = useSWRConfig()

  async function handleDelete() {
    await fetch(`/api/posts/${postId}`, { method: 'DELETE' })

    // Revalidate posts list — triggers fresh fetch
    mutate('/api/posts')
  }

  return <button onClick={handleDelete}>Delete</button>
}

// Bound mutate — from useSWR directly
function PostTitle({ id }) {
  const { data, mutate } = useSWR(`/api/posts/${id}`, fetcher)

  async function updateTitle(newTitle) {
    // Update cache immediately, revalidate in background
    await mutate(
      fetch(`/api/posts/${id}`, {
        method: 'PATCH',
        body: JSON.stringify({ title: newTitle }),
      }).then(r => r.json()),
      { optimisticData: { ...data, title: newTitle }, rollbackOnError: true }
    )
  }

  return <h1 onClick={() => updateTitle('New Title')}>{data?.title}</h1>
}

Optimistic Updates

function TodoList() {
  const { data: todos, mutate } = useSWR('/api/todos', fetcher)

  async function addTodo(text: string) {
    const newTodo = { id: Date.now(), text, done: false }

    await mutate(
      // Async mutation — runs the actual API call
      fetch('/api/todos', {
        method: 'POST',
        body: JSON.stringify({ text }),
      }).then(r => r.json()),
      {
        // Show optimistic data immediately
        optimisticData: todos ? [...todos, newTodo] : [newTodo],
        // Rollback if API call fails
        rollbackOnError: true,
        // Revalidate after mutation settles
        revalidate: true,
        // Populate cache with API response
        populateCache: (result, currentData) =>
          currentData ? [...currentData, result] : [result],
      }
    )
  }

  return (
    <div>
      {todos?.map(todo => <TodoItem key={todo.id} todo={todo} />)}
      <button onClick={() => addTodo('New task')}>Add Todo</button>
    </div>
  )
}

Infinite Loading

import useSWRInfinite from 'swr/infinite'

function PostFeed() {
  const PAGE_SIZE = 10

  const { data, size, setSize, isLoading, isValidating } = useSWRInfinite(
    (pageIndex, previousPageData) => {
      // No more pages
      if (previousPageData && !previousPageData.length) return null
      return `/api/posts?page=${pageIndex + 1}&limit=${PAGE_SIZE}`
    },
    fetcher
  )

  const posts = data ? data.flat() : []
  const isEmpty = data?.[0]?.length === 0
  const isReachingEnd = isEmpty || (data && data[data.length - 1]?.length < PAGE_SIZE)

  return (
    <div>
      {posts.map(post => <PostCard key={post.id} post={post} />)}

      {!isReachingEnd && (
        <button
          onClick={() => setSize(size + 1)}
          disabled={isValidating}
        >
          {isValidating ? 'Loading...' : 'Load More'}
        </button>
      )}
    </div>
  )
}

TypeScript

import useSWR, { SWRConfiguration } from 'swr'

interface User {
  id: string
  name: string
  email: string
  role: 'admin' | 'user'
}

interface ApiError {
  message: string
  status: number
}

// Type the generic params: <Data, Error>
function useUser(id: string) {
  return useSWR<User, ApiError>(
    id ? `/api/users/${id}` : null,
    fetcher
  )
  // data: User | undefined
  // error: ApiError | undefined
}

// Reusable typed hook factory
function createSWRHook<T>(key: string, config?: SWRConfiguration) {
  return () => useSWR<T>(key, fetcher, config)
}

const useSettings = createSWRHook<Settings>('/api/settings', {
  revalidateOnFocus: false,
})

Suspense Mode

import { Suspense } from 'react'
import useSWR from 'swr'

function UserProfile({ id }) {
  // suspense: true — component suspends while loading, no isLoading check needed
  const { data: user } = useSWR(`/api/users/${id}`, fetcher, { suspense: true })
  return <div>{user.name}</div>
}

// Wrap in Suspense + ErrorBoundary
function Page() {
  return (
    <ErrorBoundary fallback={<p>Error</p>}>
      <Suspense fallback={<Skeleton />}>
        <UserProfile id="123" />
      </Suspense>
    </ErrorBoundary>
  )
}

SWR vs TanStack Query

Feature SWR TanStack Query
Bundle size~4KB~13KB
MutationsManual (mutate)First-class (useMutation)
DevtoolsThird-partyOfficial devtools
Offline supportBasicFull (persistQueryClient)
Best forSimple fetching, Next.js projectsComplex mutations, large apps