react

SWRによるデータフェッチング - React Hooksで実現する最適なキャッシュ戦略

2025.12.02

SWRとは

SWR(stale-while-revalidate)は、Vercelが開発したReact Hooks用データフェッチングライブラリです。HTTPキャッシュ無効化戦略に基づき、高速でリアクティブなデータ取得を実現します。

SWRの動作原理

1. 初回リクエスト:

sequenceDiagram
    participant Client
    participant SWR Cache
    participant API Server
    Client->>SWR Cache: リクエスト
    SWR Cache->>API Server: fetch
    API Server-->>SWR Cache: レスポンス
    SWR Cache-->>Client: データ返却

2. 再リクエスト(キャッシュあり):

sequenceDiagram
    participant Client
    participant SWR Cache
    participant API Server
    Client->>SWR Cache: リクエスト
    SWR Cache-->>Client: 古いデータを即座に返却
    SWR Cache->>API Server: バックグラウンドで再検証
    API Server-->>SWR Cache: 新しいデータ
    SWR Cache-->>Client: 新データに更新

基本的な使い方

インストールとセットアップ

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リクエストに失敗しました');
    throw error;
  }

  return res.json();
};

// 認証付き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) {
      // トークンリフレッシュ処理など
      throw new Error('認証エラー');
    }
    throw new Error('APIリクエストに失敗しました');
  }

  return res.json();
};

useSWRフック

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>読み込み中...</div>;
  if (error) return <div>エラーが発生しました</div>;
  if (!data) return null;

  return (
    <div>
      <img src={data.avatar} alt={data.name} />
      <h1>{data.name}</h1>
      <p>{data.email}</p>
      {isValidating && <span>更新中...</span>}
      <button onClick={() => mutate()}>再取得</button>
    </div>
  );
}

グローバル設定

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

export function Providers({ children }: { children: React.ReactNode }) {
  return (
    <SWRConfig
      value={{
        fetcher,
        // グローバル設定
        revalidateOnFocus: true,
        revalidateOnReconnect: true,
        refreshInterval: 0,
        shouldRetryOnError: true,
        errorRetryCount: 3,
        errorRetryInterval: 5000,
        dedupingInterval: 2000,
        // エラーハンドリング
        onError: (error, key) => {
          console.error(`SWR Error [${key}]:`, error);
        },
        onSuccess: (data, key) => {
          console.log(`SWR Success [${key}]:`, data);
        },
      }}
    >
      {children}
    </SWRConfig>
  );
}

SWR設定オプション

カテゴリオプション説明
再検証タイミングrevalidateOnFocus: trueフォーカス時
revalidateOnReconnect: true再接続時
refreshInterval: 0定期更新 (ms)
refreshWhenHidden: false非表示時の更新
refreshWhenOffline: falseオフライン時の更新
パフォーマンスdedupingInterval: 2000重複排除間隔 (ms)
focusThrottleInterval: 5000フォーカス制限
loadingTimeout: 3000ローディング閾値
エラー処理shouldRetryOnError: trueエラー時リトライ
errorRetryCount: 3リトライ回数
errorRetryInterval: 5000リトライ間隔

条件付きフェッチ

// ユーザーがログインしている場合のみフェッチ
function Dashboard() {
  const { user } = useAuth();

  // userがnullの場合、フェッチをスキップ
  const { data: profile } = useSWR(
    user ? `/api/users/${user.id}/profile` : null,
    fetcher
  );

  // 関数でキーを返す
  const { data: posts } = useSWR(
    () => (user ? `/api/users/${user.id}/posts` : null),
    fetcher
  );

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

// 依存関係のあるフェッチ
function UserPosts({ userId }: { userId: string }) {
  const { data: user } = useSWR<User>(`/api/users/${userId}`, fetcher);

  // userがfetchされてから実行
  const { data: posts } = useSWR<Post[]>(
    user ? `/api/users/${user.id}/posts` : null,
    fetcher
  );

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

ミューテーション

楽観的更新

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 };

    // 楽観的更新: UIを即座に更新
    mutate(
      todos?.map(t => t.id === todo.id ? updatedTodo : t),
      false // 再検証をスキップ
    );

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

      // 成功後に再検証
      mutate();
    } catch (error) {
      // エラー時はロールバック
      mutate(todos, false);
      alert('更新に失敗しました');
    }
  };

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

    // 楽観的に追加
    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();

      // 一時IDを実際のIDに置換
      mutate(
        todos?.map(t => t.id === tempId ? createdTodo : t),
        false
      );
    } catch (error) {
      // ロールバック
      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>
  );
}

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('投稿作成:', result);
    } catch (error) {
      console.error('投稿失敗:', error);
    }
  };

  return (
    <form onSubmit={handleSubmit}>
      <input name="title" placeholder="タイトル" required />
      <textarea name="content" placeholder="内容" required />
      <button type="submit" disabled={isMutating}>
        {isMutating ? '投稿中...' : '投稿する'}
      </button>
      {error && <p>エラー: {error.message}</p>}
    </form>
  );
}

無限スクロール

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) => {
    // 最後のページに到達
    if (previousPageData && !previousPageData.nextCursor) return null;

    // 最初のページ
    if (pageIndex === 0) return `/api/posts?limit=${PAGE_SIZE}`;

    // 次のページ
    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>読み込み中...</div>}

      {!isReachingEnd && (
        <button
          onClick={() => setSize(size + 1)}
          disabled={isLoadingMore}
        >
          さらに読み込む
        </button>
      )}

      {isReachingEnd && !isEmpty && (
        <p>すべての投稿を表示しました</p>
      )}
    </div>
  );
}

無限スクロールの動作

flowchart TB
    subgraph Page0["pageIndex: 0"]
        Req0["/api/posts?limit=10"]
        Res0["{ posts: [...], nextCursor: 'abc' }"]
    end
    subgraph Page1["pageIndex: 1"]
        Req1["/api/posts?cursor=abc&limit=10"]
        Res1["{ posts: [...], nextCursor: 'def' }"]
    end
    subgraph Page2["pageIndex: 2"]
        Req2["/api/posts?cursor=def&limit=10"]
        Res2["{ posts: [...], nextCursor: null }"]
    end
    End["nextCursor: null → 終了"]

    Req0 --> Res0
    Res0 --> Req1
    Req1 --> Res1
    Res1 --> Req2
    Req2 --> Res2
    Res2 --> End

カスタムフック

// 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, // 認証エラーはリトライしない
    }
  );

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

プリフェッチ

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

// ホバー時にプリフェッチ
function PostLink({ postId }: { postId: string }) {
  const handleMouseEnter = () => {
    preload(`/api/posts/${postId}`, fetcher);
  };

  return (
    <Link
      href={`/posts/${postId}`}
      onMouseEnter={handleMouseEnter}
    >
      詳細を見る
    </Link>
  );
}

// ページロード時にプリフェッチ
function PostsPage() {
  useEffect(() => {
    // 人気の投稿をプリフェッチ
    preload('/api/posts/popular', fetcher);
  }, []);

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

// 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>
  );
}

エラーハンドリング

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リクエストに失敗しました',
      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="ユーザーが見つかりません" />;
      case 401:
        return <Redirect to="/login" />;
      case 500:
        return <ErrorPage message="サーバーエラーが発生しました" />;
      default:
        return <ErrorPage message={error.message} />;
    }
  }

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

// エラーバウンダリとの組み合わせ
function DataFetchingErrorBoundary({ children }: { children: React.ReactNode }) {
  return (
    <SWRConfig
      value={{
        onError: (error, key) => {
          // エラーレポートサービスに送信
          reportError(error, { key });
        },
      }}
    >
      {children}
    </SWRConfig>
  );
}

SWR vs React Query比較

機能SWRReact Query
バンドルサイズ~4KB~13KB
学習コスト低い中程度
DevToolsなし充実
ミューテーションシンプル高機能
無限スクロールuseSWRInfiniteuseInfiniteQuery
SSR対応良好良好
キャッシュ制御シンプル詳細

参考リンク

← 一覧に戻る