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比較
| 機能 | SWR | React Query |
|---|---|---|
| バンドルサイズ | ~4KB | ~13KB |
| 学習コスト | 低い | 中程度 |
| DevTools | なし | 充実 |
| ミューテーション | シンプル | 高機能 |
| 無限スクロール | useSWRInfinite | useInfiniteQuery |
| SSR対応 | 良好 | 良好 |
| キャッシュ制御 | シンプル | 詳細 |