Data Fetching with SWR - Optimal Caching Strategy with React Hooks

2025.12.02

What is SWR

SWR (stale-while-revalidate) is a data fetching library for React Hooks developed by Vercel. Based on the HTTP cache invalidation strategy, it achieves fast and reactive data fetching.

sequenceDiagram
    participant C as Client
    participant S as SWR Cache
    participant A as API Server

    Note over C,A: 1. Initial Request
    C->>S: Request data
    S->>A: Fetch (cache empty)
    A-->>S: Response
    S-->>C: Return data

    Note over C,A: 2. Subsequent Request (with cache)
    C->>S: Request data
    S-->>C: Return stale data immediately
    S->>A: Background revalidation
    A-->>S: Fresh data
    S-->>C: Update with new data

Basic Usage

Installation and Setup

npm install swr
// lib/fetcher.ts
export const fetcher = async <T>(url: string): Promise<T> => {
  const res = await fetch(url);

  if (!res.ok) {
    const error = new Error('API request failed');
    throw error;
  }

  return res.json();
};

// Authenticated fetcher
export const authFetcher = async <T>(url: string): Promise<T> => {
  const token = localStorage.getItem('token');

  const res = await fetch(url, {
    headers: {
      Authorization: `Bearer ${token}`,
    },
  });

  if (!res.ok) {
    if (res.status === 401) {
      // Token refresh handling, etc.
      throw new Error('Authentication error');
    }
    throw new Error('API request failed');
  }

  return res.json();
};

useSWR Hook

import useSWR from 'swr';
import { fetcher } from '@/lib/fetcher';

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

function UserProfile({ userId }: { userId: string }) {
  const { data, error, isLoading, isValidating, mutate } = useSWR<User>(
    `/api/users/${userId}`,
    fetcher
  );

  if (isLoading) return <div>Loading...</div>;
  if (error) return <div>An error occurred</div>;
  if (!data) return null;

  return (
    <div>
      <img src={data.avatar} alt={data.name} />
      <h1>{data.name}</h1>
      <p>{data.email}</p>
      {isValidating && <span>Updating...</span>}
      <button onClick={() => mutate()}>Refetch</button>
    </div>
  );
}

Global Configuration

// app/providers.tsx
import { SWRConfig } from 'swr';
import { fetcher } from '@/lib/fetcher';

export function Providers({ children }: { children: React.ReactNode }) {
  return (
    <SWRConfig
      value={{
        fetcher,
        // Global settings
        revalidateOnFocus: true,
        revalidateOnReconnect: true,
        refreshInterval: 0,
        shouldRetryOnError: true,
        errorRetryCount: 3,
        errorRetryInterval: 5000,
        dedupingInterval: 2000,
        // Error handling
        onError: (error, key) => {
          console.error(`SWR Error [${key}]:`, error);
        },
        onSuccess: (data, key) => {
          console.log(`SWR Success [${key}]:`, data);
        },
      }}
    >
      {children}
    </SWRConfig>
  );
}

SWR Configuration Options

CategoryOptionDefaultDescription
Revalidation TimingrevalidateOnFocustrueOn focus
revalidateOnReconnecttrueOn reconnect
refreshInterval0Periodic update (ms)
refreshWhenHiddenfalseUpdate when hidden
refreshWhenOfflinefalseUpdate when offline
PerformancededupingInterval2000Dedup interval (ms)
focusThrottleInterval5000Focus throttle
loadingTimeout3000Loading threshold
Error HandlingshouldRetryOnErrortrueRetry on error
errorRetryCount3Retry count
errorRetryInterval5000Retry interval

Conditional Fetching

// Fetch only when user is logged in
function Dashboard() {
  const { user } = useAuth();

  // Skip fetch if user is null
  const { data: profile } = useSWR(
    user ? `/api/users/${user.id}/profile` : null,
    fetcher
  );

  // Return key with function
  const { data: posts } = useSWR(
    () => (user ? `/api/users/${user.id}/posts` : null),
    fetcher
  );

  return (
    <div>
      {profile && <ProfileCard profile={profile} />}
      {posts && <PostList posts={posts} />}
    </div>
  );
}

// Dependent fetching
function UserPosts({ userId }: { userId: string }) {
  const { data: user } = useSWR<User>(`/api/users/${userId}`, fetcher);

  // Execute after user is fetched
  const { data: posts } = useSWR<Post[]>(
    user ? `/api/users/${user.id}/posts` : null,
    fetcher
  );

  return (
    <div>
      <h1>{user?.name}'s posts</h1>
      {posts?.map(post => <PostCard key={post.id} post={post} />)}
    </div>
  );
}

Mutations

Optimistic Updates

import useSWR, { useSWRConfig } from 'swr';

interface Todo {
  id: string;
  title: string;
  completed: boolean;
}

function TodoList() {
  const { data: todos, mutate } = useSWR<Todo[]>('/api/todos', fetcher);
  const { mutate: globalMutate } = useSWRConfig();

  const toggleTodo = async (todo: Todo) => {
    const updatedTodo = { ...todo, completed: !todo.completed };

    // Optimistic update: immediately update UI
    mutate(
      todos?.map(t => t.id === todo.id ? updatedTodo : t),
      false // Skip revalidation
    );

    try {
      // Send to API
      await fetch(`/api/todos/${todo.id}`, {
        method: 'PATCH',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ completed: updatedTodo.completed }),
      });

      // Revalidate after success
      mutate();
    } catch (error) {
      // Rollback on error
      mutate(todos, false);
      alert('Update failed');
    }
  };

  const addTodo = async (title: string) => {
    const tempId = `temp-${Date.now()}`;
    const newTodo: Todo = { id: tempId, title, completed: false };

    // Optimistically add
    mutate([...(todos || []), newTodo], false);

    try {
      const res = await fetch('/api/todos', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ title }),
      });

      const createdTodo = await res.json();

      // Replace temp ID with actual ID
      mutate(
        todos?.map(t => t.id === tempId ? createdTodo : t),
        false
      );
    } catch (error) {
      // Rollback
      mutate(todos?.filter(t => t.id !== tempId), false);
    }
  };

  return (
    <ul>
      {todos?.map(todo => (
        <li key={todo.id} onClick={() => toggleTodo(todo)}>
          <input type="checkbox" checked={todo.completed} readOnly />
          {todo.title}
        </li>
      ))}
    </ul>
  );
}

Explicit Mutations with useSWRMutation

import useSWRMutation from 'swr/mutation';

interface CreatePostInput {
  title: string;
  content: string;
}

async function createPost(url: string, { arg }: { arg: CreatePostInput }) {
  const res = await fetch(url, {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify(arg),
  });
  return res.json();
}

function CreatePostForm() {
  const { trigger, isMutating, error } = useSWRMutation(
    '/api/posts',
    createPost
  );

  const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
    e.preventDefault();
    const formData = new FormData(e.currentTarget);

    try {
      const result = await trigger({
        title: formData.get('title') as string,
        content: formData.get('content') as string,
      });
      console.log('Post created:', result);
    } catch (error) {
      console.error('Post failed:', error);
    }
  };

  return (
    <form onSubmit={handleSubmit}>
      <input name="title" placeholder="Title" required />
      <textarea name="content" placeholder="Content" required />
      <button type="submit" disabled={isMutating}>
        {isMutating ? 'Posting...' : 'Post'}
      </button>
      {error && <p>Error: {error.message}</p>}
    </form>
  );
}

Infinite Scroll

import useSWRInfinite from 'swr/infinite';

interface Post {
  id: string;
  title: string;
  content: string;
}

interface PostsResponse {
  posts: Post[];
  nextCursor: string | null;
}

const PAGE_SIZE = 10;

function InfinitePostList() {
  const getKey = (pageIndex: number, previousPageData: PostsResponse | null) => {
    // Reached last page
    if (previousPageData && !previousPageData.nextCursor) return null;

    // First page
    if (pageIndex === 0) return `/api/posts?limit=${PAGE_SIZE}`;

    // Next page
    return `/api/posts?cursor=${previousPageData?.nextCursor}&limit=${PAGE_SIZE}`;
  };

  const {
    data,
    error,
    size,
    setSize,
    isLoading,
    isValidating,
  } = useSWRInfinite<PostsResponse>(getKey, fetcher);

  const posts = data?.flatMap(page => page.posts) ?? [];
  const isLoadingMore = isLoading || (size > 0 && data && typeof data[size - 1] === 'undefined');
  const isEmpty = data?.[0]?.posts.length === 0;
  const isReachingEnd = isEmpty || (data && !data[data.length - 1]?.nextCursor);

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

      {isLoadingMore && <div>Loading...</div>}

      {!isReachingEnd && (
        <button
          onClick={() => setSize(size + 1)}
          disabled={isLoadingMore}
        >
          Load more
        </button>
      )}

      {isReachingEnd && !isEmpty && (
        <p>All posts displayed</p>
      )}
    </div>
  );
}
flowchart TB
    subgraph Page0["pageIndex: 0"]
        P0["/api/posts?limit=10"]
        R0["{ posts: [...], nextCursor: 'abc' }"]
        P0 --> R0
    end

    subgraph Page1["pageIndex: 1"]
        P1["/api/posts?cursor=abc&limit=10"]
        R1["{ posts: [...], nextCursor: 'def' }"]
        P1 --> R1
    end

    subgraph Page2["pageIndex: 2"]
        P2["/api/posts?cursor=def&limit=10"]
        R2["{ posts: [...], nextCursor: null }"]
        P2 --> R2
    end

    End["nextCursor: null → return null (finished)"]

    Page0 --> Page1
    Page1 --> Page2
    Page2 --> End

Custom Hooks

// hooks/usePosts.ts
import useSWR from 'swr';
import { fetcher } from '@/lib/fetcher';

interface Post {
  id: string;
  title: string;
  content: string;
  author: { id: string; name: string };
  createdAt: string;
}

interface UsePostsOptions {
  limit?: number;
  tag?: string;
}

export function usePosts(options: UsePostsOptions = {}) {
  const { limit = 10, tag } = options;
  const params = new URLSearchParams();
  params.set('limit', String(limit));
  if (tag) params.set('tag', tag);

  return useSWR<Post[]>(
    `/api/posts?${params.toString()}`,
    fetcher,
    {
      revalidateOnFocus: false,
      dedupingInterval: 10000,
    }
  );
}

export function usePost(id: string | null) {
  return useSWR<Post>(
    id ? `/api/posts/${id}` : null,
    fetcher
  );
}

// hooks/useUser.ts
export function useUser() {
  const { data, error, isLoading, mutate } = useSWR<User>(
    '/api/auth/me',
    fetcher,
    {
      revalidateOnFocus: true,
      errorRetryCount: 0, // Don't retry auth errors
    }
  );

  return {
    user: data,
    isLoading,
    isLoggedIn: !!data && !error,
    isError: error,
    mutate,
  };
}

Prefetching

import { preload } from 'swr';
import { fetcher } from '@/lib/fetcher';

// Prefetch on hover
function PostLink({ postId }: { postId: string }) {
  const handleMouseEnter = () => {
    preload(`/api/posts/${postId}`, fetcher);
  };

  return (
    <Link
      href={`/posts/${postId}`}
      onMouseEnter={handleMouseEnter}
    >
      View details
    </Link>
  );
}

// Prefetch on page load
function PostsPage() {
  useEffect(() => {
    // Prefetch popular posts
    preload('/api/posts/popular', fetcher);
  }, []);

  return <div>...</div>;
}

// Initial data with SSR/SSG
export async function getStaticProps() {
  const posts = await fetcher('/api/posts');

  return {
    props: {
      fallback: {
        '/api/posts': posts,
      },
    },
  };
}

function Page({ fallback }) {
  return (
    <SWRConfig value={{ fallback }}>
      <PostList />
    </SWRConfig>
  );
}

Error Handling

import useSWR from 'swr';

class APIError extends Error {
  status: number;

  constructor(message: string, status: number) {
    super(message);
    this.status = status;
  }
}

const fetcher = async (url: string) => {
  const res = await fetch(url);

  if (!res.ok) {
    const error = new APIError(
      'API request failed',
      res.status
    );
    throw error;
  }

  return res.json();
};

function UserProfile({ userId }: { userId: string }) {
  const { data, error, isLoading } = useSWR<User, APIError>(
    `/api/users/${userId}`,
    fetcher
  );

  if (isLoading) return <LoadingSpinner />;

  if (error) {
    switch (error.status) {
      case 404:
        return <NotFound message="User not found" />;
      case 401:
        return <Redirect to="/login" />;
      case 500:
        return <ErrorPage message="Server error occurred" />;
      default:
        return <ErrorPage message={error.message} />;
    }
  }

  return <ProfileCard user={data!} />;
}

// Combining with Error Boundary
function DataFetchingErrorBoundary({ children }: { children: React.ReactNode }) {
  return (
    <SWRConfig
      value={{
        onError: (error, key) => {
          // Send to error reporting service
          reportError(error, { key });
        },
      }}
    >
      {children}
    </SWRConfig>
  );
}

SWR vs React Query Comparison

FeatureSWRReact Query
Bundle Size~4KB~13KB
Learning CurveLowMedium
DevToolsNoneExcellent
MutationsSimpleAdvanced
Infinite ScrolluseSWRInfiniteuseInfiniteQuery
SSR SupportGoodGood
Cache ControlSimpleDetailed
← Back to list