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

2025.12.02

公式ドキュメント

SWRとは

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

ポイント: SWRは「まずキャッシュを返し、バックグラウンドで再検証する」というstale-while-revalidateパターンで動作します。ユーザーは即座にデータを見れるため、体感速度が大幅に向上します

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: 新データに更新

実践メモ: SWRのキーは文字列またはnullです。nullを渡すとフェッチがスキップされるため、条件付きフェッチに活用できます。

注意: fetcher関数はグローバルに設定することを推奨します。各コンポーネントでfetcherを定義すると、同じAPIに対して異なるキャッシュが生成されてしまいます。

基本的な使い方

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

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>更新中...}
 <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対応良好良好
キャッシュ制御シンプル詳細

まとめ

この記事では、手順を追って動かしながら重要な確認ポイントを押さえることが大切です。まずは学習用の最小構成で動作確認し、本番利用する前にセキュリティ、権限、エラー処理、運用手順を見直してください。

参考リンク

参考リソース

← 一覧に戻る
PR
PR
PR
PR