SWR完全ガイド:Reactのデータフェッチングを劇的に改善する


SWR完全ガイド:Reactのデータフェッチングを劇的に改善する

SWR (stale-while-revalidate) は、Vercelが開発したReact Hooksライブラリで、データフェッチングを劇的にシンプルかつ効率的にします。このガイドでは、基本から応用まで徹底解説します。

SWRとは?

SWRは、HTTP RFC 5861で提唱された「stale-while-revalidate」戦略を実装したReact Hooksライブラリです。

主な特徴

  • キャッシュファースト: 即座にキャッシュデータを表示し、バックグラウンドで再検証
  • リアルタイム更新: フォーカス時、ネットワーク復帰時の自動再検証
  • 楽観的UI: mutateによる即座のUI更新
  • TypeScript完全対応: 型安全なデータフェッチング
  • 軽量: 5KB以下のバンドルサイズ

なぜSWRが必要か?

従来のuseEffectによるデータフェッチングには多くの問題があります。

// 従来の方法(アンチパターン)
function UserProfile({ userId }: { userId: string }) {
  const [user, setUser] = useState<User | null>(null);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState<Error | null>(null);

  useEffect(() => {
    let canceled = false;
    setLoading(true);

    fetch(`/api/users/${userId}`)
      .then(res => res.json())
      .then(data => {
        if (!canceled) {
          setUser(data);
          setLoading(false);
        }
      })
      .catch(err => {
        if (!canceled) {
          setError(err);
          setLoading(false);
        }
      });

    return () => {
      canceled = true;
    };
  }, [userId]);

  if (loading) return <div>Loading...</div>;
  if (error) return <div>Error: {error.message}</div>;
  return <div>{user?.name}</div>;
}

SWRならこれが一行に:

// SWRの方法
function UserProfile({ userId }: { userId: string }) {
  const { data: user, error, isLoading } = useSWR(`/api/users/${userId}`);

  if (isLoading) return <div>Loading...</div>;
  if (error) return <div>Error: {error.message}</div>;
  return <div>{user?.name}</div>;
}

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

インストール

npm install swr
# または
pnpm add swr
# または
yarn add swr

基本的な使い方

import useSWR from 'swr';

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

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

  if (isLoading) return <div>Loading...</div>;
  if (error) return <div>Failed to load</div>;
  return <div>Hello {data.name}!</div>;
}

グローバル設定

アプリケーション全体でfetcherを共有するには、SWRConfigを使用します。

import { SWRConfig } from 'swr';

// グローバルfetcher
const fetcher = (url: string) => fetch(url).then(res => {
  if (!res.ok) throw new Error('API error');
  return res.json();
});

function App() {
  return (
    <SWRConfig
      value={{
        fetcher,
        revalidateOnFocus: true,
        revalidateOnReconnect: true,
        dedupingInterval: 2000,
      }}
    >
      <MyApplication />
    </SWRConfig>
  );
}

TypeScriptでの型定義

SWRは完全な型安全性を提供します。

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

interface ApiError {
  message: string;
  status: number;
}

// 型付きfetcher
const fetcher = async <T,>(url: string): Promise<T> => {
  const res = await fetch(url);
  if (!res.ok) {
    const error: ApiError = {
      message: 'API request failed',
      status: 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 <div>Loading...</div>;
  if (error) return <div>Error: {error.message} (Status: {error.status})</div>;
  if (!data) return null;

  return (
    <div>
      <h1>{data.name}</h1>
      <p>{data.email}</p>
      {data.avatar && <img src={data.avatar} alt={data.name} />}
    </div>
  );
}

キャッシュ戦略

SWRの真価はキャッシュ戦略にあります。

自動再検証

const { data } = useSWR('/api/data', fetcher, {
  // ウィンドウフォーカス時に再検証(デフォルト: true)
  revalidateOnFocus: true,

  // ネットワーク再接続時に再検証(デフォルト: true)
  revalidateOnReconnect: true,

  // 定期的な再検証(ミリ秒)
  refreshInterval: 3000,

  // ウィンドウが見えているときだけ定期的再検証
  refreshWhenHidden: false,
  refreshWhenOffline: false,

  // 重複リクエストの排除時間(ミリ秒、デフォルト: 2000)
  dedupingInterval: 2000,
});

条件付きフェッチング

// userIdがnullの場合はフェッチしない
const { data: user } = useSWR(
  userId ? `/api/users/${userId}` : null,
  fetcher
);

// 複数の条件
const { data } = useSWR(
  () => (shouldFetch && userId ? `/api/users/${userId}` : null),
  fetcher
);

依存データのフェッチング

function UserPosts({ userId }: { userId: string }) {
  // まずユーザーを取得
  const { data: user } = useSWR<User>(`/api/users/${userId}`, fetcher);

  // ユーザーが取得できてから投稿を取得
  const { data: posts } = useSWR<Post[]>(
    user ? `/api/users/${user.id}/posts` : null,
    fetcher
  );

  if (!user) return <div>Loading user...</div>;
  if (!posts) return <div>Loading posts...</div>;

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

mutateによる楽観的UI更新

SWRの最も強力な機能の一つがmutateです。

基本的なmutate

import useSWR, { mutate } from 'swr';

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

  const addTodo = async (title: string) => {
    const newTodo = { id: Date.now(), title, completed: false };

    // 楽観的更新: すぐにUIを更新
    mutate('/api/todos', [...(todos || []), newTodo], false);

    // APIリクエスト
    await fetch('/api/todos', {
      method: 'POST',
      body: JSON.stringify({ title }),
      headers: { 'Content-Type': 'application/json' },
    });

    // 再検証
    mutate('/api/todos');
  };

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

ローカルmutate

function TodoItem({ todo }: { todo: Todo }) {
  const { data, mutate } = useSWR<Todo>(`/api/todos/${todo.id}`, fetcher);

  const toggleComplete = async () => {
    // ローカルmutate: このコンポーネントのデータのみ更新
    mutate({ ...data!, completed: !data!.completed }, false);

    await fetch(`/api/todos/${todo.id}`, {
      method: 'PATCH',
      body: JSON.stringify({ completed: !data!.completed }),
    });

    mutate();
  };

  return (
    <div onClick={toggleComplete}>
      {data?.completed ? '✓' : '○'} {data?.title}
    </div>
  );
}

グローバルmutate

import { mutate } from 'swr';

// すべてのキャッシュを再検証
mutate(() => true);

// 特定のパターンのキャッシュを再検証
mutate(key => typeof key === 'string' && key.startsWith('/api/users'));

// 削除後、関連するすべてのデータを再検証
const deleteUser = async (userId: string) => {
  await fetch(`/api/users/${userId}`, { method: 'DELETE' });

  // ユーザー一覧を再検証
  mutate('/api/users');

  // 削除したユーザーのキャッシュを削除
  mutate(`/api/users/${userId}`, undefined, false);
};

Pagination(ページネーション)

基本的なページネーション

function UserList() {
  const [page, setPage] = useState(1);
  const { data: users, isLoading } = useSWR<User[]>(
    `/api/users?page=${page}&limit=10`,
    fetcher
  );

  return (
    <div>
      {isLoading ? (
        <div>Loading...</div>
      ) : (
        <ul>
          {users?.map(user => (
            <li key={user.id}>{user.name}</li>
          ))}
        </ul>
      )}
      <button onClick={() => setPage(p => Math.max(1, p - 1))}>Previous</button>
      <button onClick={() => setPage(p => p + 1)}>Next</button>
    </div>
  );
}

無限スクロール (useSWRInfinite)

import useSWRInfinite from 'swr/infinite';

interface PageData {
  users: User[];
  hasMore: boolean;
}

function InfiniteUserList() {
  const getKey = (pageIndex: number, previousPageData: PageData | null) => {
    // 最後のページに到達
    if (previousPageData && !previousPageData.hasMore) return null;

    // ページのキーを返す
    return `/api/users?page=${pageIndex + 1}&limit=20`;
  };

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

  const users = data ? data.flatMap(page => page.users) : [];
  const hasMore = data?.[data.length - 1]?.hasMore ?? true;

  return (
    <div>
      <ul>
        {users.map(user => (
          <li key={user.id}>{user.name}</li>
        ))}
      </ul>

      {isLoading && <div>Loading...</div>}
      {isValidating && !isLoading && <div>Updating...</div>}

      {hasMore && (
        <button onClick={() => setSize(size + 1)} disabled={isValidating}>
          Load More
        </button>
      )}
    </div>
  );
}

Intersection Observerとの組み合わせ

import { useRef, useEffect } from 'react';
import useSWRInfinite from 'swr/infinite';

function AutoLoadUserList() {
  const observerTarget = useRef<HTMLDivElement>(null);

  const getKey = (pageIndex: number, previousPageData: PageData | null) => {
    if (previousPageData && !previousPageData.hasMore) return null;
    return `/api/users?page=${pageIndex + 1}&limit=20`;
  };

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

  const users = data ? data.flatMap(page => page.users) : [];
  const hasMore = data?.[data.length - 1]?.hasMore ?? true;

  useEffect(() => {
    const observer = new IntersectionObserver(
      entries => {
        if (entries[0].isIntersecting && hasMore && !isValidating) {
          setSize(size + 1);
        }
      },
      { threshold: 1 }
    );

    if (observerTarget.current) {
      observer.observe(observerTarget.current);
    }

    return () => observer.disconnect();
  }, [hasMore, isValidating, size, setSize]);

  return (
    <div>
      <ul>
        {users.map(user => (
          <li key={user.id}>{user.name}</li>
        ))}
      </ul>
      {hasMore && <div ref={observerTarget}>Loading...</div>}
    </div>
  );
}

Next.jsでの活用

SWRはNext.jsと完璧に統合できます。

SSR/SSGとの組み合わせ

// pages/users/[id].tsx
import type { GetStaticProps, GetStaticPaths } from 'next';
import useSWR from 'swr';

interface Props {
  fallback: {
    [key: string]: User;
  };
}

export default function UserPage({ fallback }: Props) {
  const router = useRouter();
  const { id } = router.query;

  // fallbackデータを使用して即座に表示、その後再検証
  const { data: user } = useSWR<User>(`/api/users/${id}`, fetcher, {
    fallbackData: fallback[`/api/users/${id}`],
  });

  return (
    <div>
      <h1>{user?.name}</h1>
      <p>{user?.email}</p>
    </div>
  );
}

export const getStaticProps: GetStaticProps = async ({ params }) => {
  const user = await fetch(`https://api.example.com/users/${params?.id}`).then(res => res.json());

  return {
    props: {
      fallback: {
        [`/api/users/${params?.id}`]: user,
      },
    },
    revalidate: 60, // 60秒ごとにISR
  };
};

export const getStaticPaths: GetStaticPaths = async () => {
  const users = await fetch('https://api.example.com/users').then(res => res.json());

  return {
    paths: users.map((user: User) => ({ params: { id: user.id } })),
    fallback: 'blocking',
  };
};

App Routerでの使用

// app/users/[id]/page.tsx
import { Suspense } from 'react';
import UserProfile from './UserProfile';

async function getUser(id: string) {
  const res = await fetch(`https://api.example.com/users/${id}`, {
    next: { revalidate: 60 },
  });
  return res.json();
}

export default async function UserPage({ params }: { params: { id: string } }) {
  const initialUser = await getUser(params.id);

  return (
    <Suspense fallback={<div>Loading...</div>}>
      <UserProfile userId={params.id} initialData={initialUser} />
    </Suspense>
  );
}

// app/users/[id]/UserProfile.tsx (Client Component)
'use client';

import useSWR from 'swr';

export default function UserProfile({ userId, initialData }: Props) {
  const { data: user } = useSWR<User>(
    `/api/users/${userId}`,
    fetcher,
    { fallbackData: initialData }
  );

  return (
    <div>
      <h1>{user?.name}</h1>
      <p>{user?.email}</p>
    </div>
  );
}

Middleware統合

// lib/swr-config.ts
import { SWRConfiguration } from 'swr';

export const swrConfig: SWRConfiguration = {
  fetcher: async (url: string) => {
    const res = await fetch(url, {
      headers: {
        'Authorization': `Bearer ${getToken()}`,
      },
    });

    if (!res.ok) {
      const error = new Error('API Error');
      error.info = await res.json();
      error.status = res.status;
      throw error;
    }

    return res.json();
  },

  onError: (error) => {
    console.error('SWR Error:', error);

    if (error.status === 401) {
      // 認証エラー時の処理
      window.location.href = '/login';
    }
  },

  onSuccess: (data, key, config) => {
    console.log('SWR Success:', key);
  },
};

// app/layout.tsx
import { SWRConfig } from 'swr';
import { swrConfig } from '@/lib/swr-config';

export default function RootLayout({ children }: Props) {
  return (
    <html>
      <body>
        <SWRConfig value={swrConfig}>
          {children}
        </SWRConfig>
      </body>
    </html>
  );
}

エラーハンドリング

エラー再試行

const { data, error } = useSWR('/api/data', fetcher, {
  // エラー時の再試行設定
  errorRetryCount: 3,
  errorRetryInterval: 5000,

  // 特定のエラーコードでは再試行しない
  shouldRetryOnError: (error) => {
    return error.status !== 404 && error.status !== 403;
  },

  // 再試行時のバックオフ
  onErrorRetry: (error, key, config, revalidate, { retryCount }) => {
    // 404では再試行しない
    if (error.status === 404) return;

    // 最大3回まで
    if (retryCount >= 3) return;

    // 指数バックオフ: 1秒、2秒、4秒
    setTimeout(() => revalidate({ retryCount }), 1000 * Math.pow(2, retryCount));
  },
});

エラーバウンダリ

import { ErrorBoundary } from 'react-error-boundary';

function ErrorFallback({ error, resetErrorBoundary }: ErrorFallbackProps) {
  return (
    <div role="alert">
      <p>Something went wrong:</p>
      <pre>{error.message}</pre>
      <button onClick={resetErrorBoundary}>Try again</button>
    </div>
  );
}

function App() {
  return (
    <ErrorBoundary FallbackComponent={ErrorFallback}>
      <SWRConfig value={{ suspense: true }}>
        <DataComponent />
      </SWRConfig>
    </ErrorBoundary>
  );
}

パフォーマンス最適化

Prefetching

import { mutate } from 'swr';

function UserList() {
  const { data: users } = useSWR<User[]>('/api/users', fetcher);

  const prefetchUser = (userId: string) => {
    // マウスホバーでプリフェッチ
    mutate(`/api/users/${userId}`, fetcher(`/api/users/${userId}`), false);
  };

  return (
    <ul>
      {users?.map(user => (
        <li key={user.id} onMouseEnter={() => prefetchUser(user.id)}>
          <Link href={`/users/${user.id}`}>{user.name}</Link>
        </li>
      ))}
    </ul>
  );
}

キャッシュの永続化

import { SWRConfig } from 'swr';

const localStorageProvider = () => {
  const map = new Map<string, any>(JSON.parse(localStorage.getItem('app-cache') || '[]'));

  window.addEventListener('beforeunload', () => {
    const appCache = JSON.stringify(Array.from(map.entries()));
    localStorage.setItem('app-cache', appCache);
  });

  return map;
};

function App() {
  return (
    <SWRConfig value={{ provider: localStorageProvider }}>
      <MyApplication />
    </SWRConfig>
  );
}

選択的再検証

function UserDashboard() {
  const { data: profile } = useSWR('/api/profile', fetcher, {
    revalidateOnFocus: false, // プロフィールは頻繁に変わらない
  });

  const { data: notifications } = useSWR('/api/notifications', fetcher, {
    refreshInterval: 10000, // 通知は10秒ごとに更新
  });

  const { data: messages } = useSWR('/api/messages', fetcher, {
    revalidateOnFocus: true, // メッセージはフォーカス時に更新
  });

  return (
    <div>
      <Profile data={profile} />
      <Notifications data={notifications} />
      <Messages data={messages} />
    </div>
  );
}

実践パターン

検索機能

import { useState } from 'react';
import useSWR from 'swr';
import { useDebouncedValue } from './hooks';

function SearchUsers() {
  const [query, setQuery] = useState('');
  const debouncedQuery = useDebouncedValue(query, 300);

  const { data: results, isLoading } = useSWR(
    debouncedQuery ? `/api/search?q=${debouncedQuery}` : null,
    fetcher,
    {
      keepPreviousData: true, // 検索中も前の結果を表示
    }
  );

  return (
    <div>
      <input
        type="text"
        value={query}
        onChange={e => setQuery(e.target.value)}
        placeholder="Search users..."
      />

      {isLoading && <div>Searching...</div>}

      <ul>
        {results?.map(user => (
          <li key={user.id}>{user.name}</li>
        ))}
      </ul>
    </div>
  );
}

フォーム送信

function UserEditForm({ userId }: { userId: string }) {
  const { data: user, mutate } = useSWR<User>(`/api/users/${userId}`, fetcher);
  const [name, setName] = useState('');

  useEffect(() => {
    if (user) setName(user.name);
  }, [user]);

  const handleSubmit = async (e: React.FormEvent) => {
    e.preventDefault();

    // 楽観的更新
    mutate({ ...user!, name }, false);

    try {
      const updated = await fetch(`/api/users/${userId}`, {
        method: 'PATCH',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ name }),
      }).then(res => res.json());

      // 成功時: サーバーデータで更新
      mutate(updated, false);
    } catch (error) {
      // エラー時: 元のデータに戻す
      mutate();
    }
  };

  if (!user) return <div>Loading...</div>;

  return (
    <form onSubmit={handleSubmit}>
      <input
        type="text"
        value={name}
        onChange={e => setName(e.target.value)}
      />
      <button type="submit">Save</button>
    </form>
  );
}

リアルタイム更新

import { useEffect } from 'react';
import useSWR from 'swr';

function LiveChat({ roomId }: { roomId: string }) {
  const { data: messages, mutate } = useSWR<Message[]>(
    `/api/rooms/${roomId}/messages`,
    fetcher,
    { refreshInterval: 3000 } // 3秒ごとにポーリング
  );

  useEffect(() => {
    // WebSocketでリアルタイム更新
    const ws = new WebSocket(`wss://api.example.com/rooms/${roomId}`);

    ws.onmessage = (event) => {
      const newMessage = JSON.parse(event.data);

      // 新しいメッセージを追加
      mutate(
        messages ? [...messages, newMessage] : [newMessage],
        false
      );
    };

    return () => ws.close();
  }, [roomId, messages, mutate]);

  return (
    <div>
      {messages?.map(msg => (
        <div key={msg.id}>{msg.text}</div>
      ))}
    </div>
  );
}

まとめ

SWRは、Reactアプリケーションのデータフェッチングを劇的に改善します。

主な利点

  • シンプルなAPI: useEffectの複雑さから解放
  • パフォーマンス: キャッシュファーストで高速表示
  • UX向上: 楽観的更新でスムーズな操作感
  • TypeScript対応: 完全な型安全性
  • Next.js統合: SSR/SSG/ISRとシームレスに連携

いつ使うべきか

  • 使うべき: REST API、GraphQL、任意の非同期データソース
  • 検討すべき: サーバーステート中心のアプリ(React QueryやTanStack Queryも検討)
  • 不要: クライアントステートのみ(Zustand、Jotai等で十分)

SWRをマスターすることで、よりユーザーフレンドリーで高速なReactアプリケーションを構築できます。