Day 5: RTK Queryの基本
今日学ぶこと
- RTK Queryとは何か、なぜ手動データ取得を置き換えるのか
createApiとfetchBaseQueryのセットアップ- クエリエンドポイント(GET操作)の定義
- ミューテーションエンドポイント(POST/PUT/DELETE)の定義
- 自動生成フック:
useGetXQuery、useXMutation - APIスライスのストアへの統合
- 自動キャッシュの仕組み
- ローディング・エラー・成功状態の扱い方
RTK Queryとは
RTK Queryは、Redux Toolkitに組み込まれたデータフェッチングとキャッシングのソリューションです。従来のReduxでのデータ取得パターンを大幅に簡略化します。
flowchart LR
subgraph Before["従来のアプローチ"]
A1["createAsyncThunk"] --> A2["extraReducers"]
A2 --> A3["loading状態管理"]
A3 --> A4["エラーハンドリング"]
A4 --> A5["キャッシュ管理"]
A5 --> A6["再取得ロジック"]
end
subgraph After["RTK Query"]
B1["createApi"] --> B2["自動生成フック"]
B2 --> B3["すべて自動管理"]
end
style Before fill:#ef4444,color:#fff
style After fill:#22c55e,color:#fff
なぜRTK Queryを使うのか
従来のcreateAsyncThunkでデータを取得する場合、多くのボイラープレートコードが必要でした。
// ❌ 従来のアプローチ: 大量のボイラープレート
import { createSlice, createAsyncThunk } from '@reduxjs/toolkit';
// Thunkの定義
const fetchPosts = createAsyncThunk('posts/fetchAll', async () => {
const response = await fetch('/api/posts');
return response.json();
});
const fetchPostById = createAsyncThunk('posts/fetchById', async (id) => {
const response = await fetch(`/api/posts/${id}`);
return response.json();
});
const createPost = createAsyncThunk('posts/create', async (newPost) => {
const response = await fetch('/api/posts', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(newPost),
});
return response.json();
});
// スライスの定義
const postsSlice = createSlice({
name: 'posts',
initialState: {
items: [],
currentPost: null,
loading: false,
error: null,
},
reducers: {},
extraReducers: (builder) => {
builder
.addCase(fetchPosts.pending, (state) => {
state.loading = true;
state.error = null;
})
.addCase(fetchPosts.fulfilled, (state, action) => {
state.loading = false;
state.items = action.payload;
})
.addCase(fetchPosts.rejected, (state, action) => {
state.loading = false;
state.error = action.error.message;
})
// fetchPostById, createPost も同様に...
},
});
TypeScript版
import { createSlice, createAsyncThunk, PayloadAction } from '@reduxjs/toolkit';
interface Post {
id: number;
title: string;
body: string;
}
interface PostsState {
items: Post[];
currentPost: Post | null;
loading: boolean;
error: string | null;
}
const fetchPosts = createAsyncThunk<Post[]>('posts/fetchAll', async () => {
const response = await fetch('/api/posts');
return response.json();
});
const fetchPostById = createAsyncThunk<Post, number>(
'posts/fetchById',
async (id) => {
const response = await fetch(`/api/posts/${id}`);
return response.json();
}
);
const createPost = createAsyncThunk<Post, Omit<Post, 'id'>>(
'posts/create',
async (newPost) => {
const response = await fetch('/api/posts', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(newPost),
});
return response.json();
}
);
const postsSlice = createSlice({
name: 'posts',
initialState: {
items: [],
currentPost: null,
loading: false,
error: null,
} as PostsState,
reducers: {},
extraReducers: (builder) => {
builder
.addCase(fetchPosts.pending, (state) => {
state.loading = true;
state.error = null;
})
.addCase(fetchPosts.fulfilled, (state, action: PayloadAction<Post[]>) => {
state.loading = false;
state.items = action.payload;
})
.addCase(fetchPosts.rejected, (state, action) => {
state.loading = false;
state.error = action.error.message ?? 'Unknown error';
});
},
});
RTK Queryを使えば、これらすべてが劇的に簡潔になります。
createApiとfetchBaseQuery
RTK Queryの中心はcreateApi関数です。APIのベースURLとエンドポイントを定義するだけで、Reactフックが自動生成されます。
// features/api/postsApi.js
import { createApi, fetchBaseQuery } from '@reduxjs/toolkit/query/react';
export const postsApi = createApi({
// ストアのreducerパスを指定
reducerPath: 'postsApi',
// ベースURLの設定
baseQuery: fetchBaseQuery({
baseUrl: 'https://jsonplaceholder.typicode.com',
}),
// エンドポイントの定義
endpoints: (builder) => ({
// ここにクエリとミューテーションを定義
}),
});
TypeScript版
import { createApi, fetchBaseQuery } from '@reduxjs/toolkit/query/react';
interface Post {
id: number;
title: string;
body: string;
userId: number;
}
export const postsApi = createApi({
reducerPath: 'postsApi',
baseQuery: fetchBaseQuery({
baseUrl: 'https://jsonplaceholder.typicode.com',
}),
endpoints: (builder) => ({
// endpoints will be defined here
}),
});
fetchBaseQueryの設定オプション
fetchBaseQueryはfetch APIのラッパーで、よく使うオプションを設定できます。
const baseQuery = fetchBaseQuery({
baseUrl: 'https://api.example.com',
// リクエストごとにヘッダーを付与
prepareHeaders: (headers, { getState }) => {
const token = getState().auth.token;
if (token) {
headers.set('Authorization', `Bearer ${token}`);
}
return headers;
},
// タイムアウト設定(ミリ秒)
timeout: 10000,
});
TypeScript版
import { fetchBaseQuery } from '@reduxjs/toolkit/query/react';
import { RootState } from '../../store';
const baseQuery = fetchBaseQuery({
baseUrl: 'https://api.example.com',
prepareHeaders: (headers, { getState }) => {
const token = (getState() as RootState).auth.token;
if (token) {
headers.set('Authorization', `Bearer ${token}`);
}
return headers;
},
timeout: 10000,
});
クエリエンドポイントの定義
クエリエンドポイントはGET操作(データの取得)に使います。builder.queryで定義します。
export const postsApi = createApi({
reducerPath: 'postsApi',
baseQuery: fetchBaseQuery({
baseUrl: 'https://jsonplaceholder.typicode.com',
}),
endpoints: (builder) => ({
// 全記事の取得
getPosts: builder.query({
query: () => '/posts',
}),
// IDによる記事の取得
getPostById: builder.query({
query: (id) => `/posts/${id}`,
}),
// ユーザーの記事を取得(クエリパラメータ付き)
getPostsByUser: builder.query({
query: (userId) => `/posts?userId=${userId}`,
}),
}),
});
// フックが自動生成される
export const {
useGetPostsQuery,
useGetPostByIdQuery,
useGetPostsByUserQuery,
} = postsApi;
TypeScript版
interface Post {
id: number;
title: string;
body: string;
userId: number;
}
export const postsApi = createApi({
reducerPath: 'postsApi',
baseQuery: fetchBaseQuery({
baseUrl: 'https://jsonplaceholder.typicode.com',
}),
endpoints: (builder) => ({
getPosts: builder.query<Post[], void>({
query: () => '/posts',
}),
getPostById: builder.query<Post, number>({
query: (id) => `/posts/${id}`,
}),
getPostsByUser: builder.query<Post[], number>({
query: (userId) => `/posts?userId=${userId}`,
}),
}),
});
export const {
useGetPostsQuery,
useGetPostByIdQuery,
useGetPostsByUserQuery,
} = postsApi;
フックの命名規則
RTK Queryはエンドポイント名からフック名を自動生成します。
| エンドポイント名 | 生成されるフック |
|---|---|
getPosts |
useGetPostsQuery |
getPostById |
useGetPostByIdQuery |
createPost |
useCreatePostMutation |
updatePost |
useUpdatePostMutation |
deletePost |
useDeletePostMutation |
ミューテーションエンドポイントの定義
ミューテーションはPOST/PUT/DELETE操作(データの変更)に使います。builder.mutationで定義します。
export const postsApi = createApi({
reducerPath: 'postsApi',
baseQuery: fetchBaseQuery({
baseUrl: 'https://jsonplaceholder.typicode.com',
}),
endpoints: (builder) => ({
// GET: 全記事の取得
getPosts: builder.query({
query: () => '/posts',
}),
// GET: 単一記事の取得
getPostById: builder.query({
query: (id) => `/posts/${id}`,
}),
// POST: 記事の作成
createPost: builder.mutation({
query: (newPost) => ({
url: '/posts',
method: 'POST',
body: newPost,
}),
}),
// PUT: 記事の更新
updatePost: builder.mutation({
query: ({ id, ...patch }) => ({
url: `/posts/${id}`,
method: 'PUT',
body: patch,
}),
}),
// DELETE: 記事の削除
deletePost: builder.mutation({
query: (id) => ({
url: `/posts/${id}`,
method: 'DELETE',
}),
}),
}),
});
export const {
useGetPostsQuery,
useGetPostByIdQuery,
useCreatePostMutation,
useUpdatePostMutation,
useDeletePostMutation,
} = postsApi;
TypeScript版
interface Post {
id: number;
title: string;
body: string;
userId: number;
}
type NewPost = Omit<Post, 'id'>;
type UpdatePost = Partial<Post> & Pick<Post, 'id'>;
export const postsApi = createApi({
reducerPath: 'postsApi',
baseQuery: fetchBaseQuery({
baseUrl: 'https://jsonplaceholder.typicode.com',
}),
endpoints: (builder) => ({
getPosts: builder.query<Post[], void>({
query: () => '/posts',
}),
getPostById: builder.query<Post, number>({
query: (id) => `/posts/${id}`,
}),
createPost: builder.mutation<Post, NewPost>({
query: (newPost) => ({
url: '/posts',
method: 'POST',
body: newPost,
}),
}),
updatePost: builder.mutation<Post, UpdatePost>({
query: ({ id, ...patch }) => ({
url: `/posts/${id}`,
method: 'PUT',
body: patch,
}),
}),
deletePost: builder.mutation<{ success: boolean }, number>({
query: (id) => ({
url: `/posts/${id}`,
method: 'DELETE',
}),
}),
}),
});
export const {
useGetPostsQuery,
useGetPostByIdQuery,
useCreatePostMutation,
useUpdatePostMutation,
useDeletePostMutation,
} = postsApi;
APIスライスのストアへの統合
createApiで作成したAPIスライスは、ストアにreducerとmiddlewareの両方を追加する必要があります。
// store.js
import { configureStore } from '@reduxjs/toolkit';
import { postsApi } from './features/api/postsApi';
export const store = configureStore({
reducer: {
// APIのreducerを追加(reducerPathをキーとして使用)
[postsApi.reducerPath]: postsApi.reducer,
},
// APIのミドルウェアを追加(キャッシュ管理、ポーリング等に必要)
middleware: (getDefaultMiddleware) =>
getDefaultMiddleware().concat(postsApi.middleware),
});
TypeScript版
import { configureStore } from '@reduxjs/toolkit';
import { postsApi } from './features/api/postsApi';
export const store = configureStore({
reducer: {
[postsApi.reducerPath]: postsApi.reducer,
},
middleware: (getDefaultMiddleware) =>
getDefaultMiddleware().concat(postsApi.middleware),
});
export type RootState = ReturnType<typeof store.getState>;
export type AppDispatch = typeof store.dispatch;
なぜmiddlewareが必要か
RTK Queryのmiddlewareは以下の機能を提供します。
flowchart TB
subgraph Middleware["RTK Query Middleware"]
M1["キャッシュの有効期限管理"]
M2["リクエストの重複排除"]
M3["ポーリングの制御"]
M4["参照カウント管理"]
M5["未使用キャッシュの自動クリーンアップ"]
end
style Middleware fill:#8b5cf6,color:#fff
自動キャッシュの仕組み
RTK Queryの最も強力な機能の一つが自動キャッシュです。同じデータに対するリクエストは自動的に重複排除されます。
sequenceDiagram
participant C1 as コンポーネントA
participant C2 as コンポーネントB
participant RTK as RTK Query Cache
participant API as APIサーバー
C1->>RTK: useGetPostsQuery()
RTK->>API: GET /posts
API-->>RTK: [posts data]
RTK-->>C1: データ返却
Note over RTK: キャッシュに保存済み
C2->>RTK: useGetPostsQuery()
RTK-->>C2: キャッシュから即座に返却
Note over C2: APIリクエストなし!
キャッシュのライフサイクル
// コンポーネントAがマウント → データ取得開始
function PostList() {
const { data, isLoading } = useGetPostsQuery();
// コンポーネントBが同じクエリを使っても、
// 追加のAPIリクエストは発生しない
}
// コンポーネントBも同じデータを使用
function PostSidebar() {
const { data } = useGetPostsQuery();
// キャッシュからデータを取得(APIリクエストなし)
}
keepUnusedDataForオプション
デフォルトでは、データを使用しているコンポーネントがすべてアンマウントされると60秒後にキャッシュが削除されます。この時間は変更可能です。
// グローバル設定
const api = createApi({
baseQuery: fetchBaseQuery({ baseUrl: '/api' }),
// 全エンドポイントに適用
keepUnusedDataFor: 300, // 5分
endpoints: (builder) => ({
getPosts: builder.query({
query: () => '/posts',
// エンドポイント個別に設定も可能
keepUnusedDataFor: 600, // 10分
}),
}),
});
フックの返り値:ローディング・エラー・成功状態
クエリフックは豊富な状態情報を返します。
function PostList() {
const {
data, // 取得したデータ
error, // エラー情報
isLoading, // 初回ロード中
isFetching, // データ取得中(再取得含む)
isSuccess, // データ取得成功
isError, // エラー発生
isUninitialized, // まだリクエストが開始されていない
refetch, // 手動で再取得する関数
} = useGetPostsQuery();
if (isLoading) return <p>読み込み中...</p>;
if (isError) return <p>エラー: {error.message}</p>;
return (
<ul>
{data?.map((post) => (
<li key={post.id}>{post.title}</li>
))}
</ul>
);
}
TypeScript版
import { useGetPostsQuery } from './features/api/postsApi';
function PostList() {
const {
data,
error,
isLoading,
isFetching,
isSuccess,
isError,
refetch,
} = useGetPostsQuery();
if (isLoading) return <p>読み込み中...</p>;
if (isError) return <p>エラーが発生しました</p>;
return (
<ul>
{data?.map((post) => (
<li key={post.id}>{post.title}</li>
))}
</ul>
);
}
isLoadingとisFetchingの違い
| 状態 | isLoading | isFetching | 説明 |
|---|---|---|---|
| 初回ロード | true |
true |
キャッシュにデータがない |
| 再取得中 | false |
true |
キャッシュにデータがあり、バックグラウンドで更新中 |
| 取得完了 | false |
false |
データ取得済み |
function PostList() {
const { data, isLoading, isFetching } = useGetPostsQuery();
return (
<div>
{/* 初回のみスピナー表示 */}
{isLoading && <Spinner />}
{/* 再取得中は薄く表示 */}
<div style={{ opacity: isFetching ? 0.5 : 1 }}>
{data?.map((post) => (
<PostCard key={post.id} post={post} />
))}
</div>
</div>
);
}
ミューテーションフックの使い方
ミューテーションフックはクエリフックとは異なり、トリガー関数と結果オブジェクトのタプルを返します。
function CreatePostForm() {
const [createPost, { isLoading, isSuccess, isError, error }] =
useCreatePostMutation();
const [title, setTitle] = useState('');
const [body, setBody] = useState('');
const handleSubmit = async (e) => {
e.preventDefault();
try {
await createPost({ title, body, userId: 1 }).unwrap();
setTitle('');
setBody('');
alert('記事を作成しました!');
} catch (err) {
console.error('作成に失敗:', err);
}
};
return (
<form onSubmit={handleSubmit}>
<input
value={title}
onChange={(e) => setTitle(e.target.value)}
placeholder="タイトル"
/>
<textarea
value={body}
onChange={(e) => setBody(e.target.value)}
placeholder="本文"
/>
<button type="submit" disabled={isLoading}>
{isLoading ? '作成中...' : '投稿する'}
</button>
{isError && <p>エラー: {error.message}</p>}
</form>
);
}
TypeScript版
import { useState, FormEvent } from 'react';
import { useCreatePostMutation } from './features/api/postsApi';
function CreatePostForm() {
const [createPost, { isLoading, isError, error }] =
useCreatePostMutation();
const [title, setTitle] = useState('');
const [body, setBody] = useState('');
const handleSubmit = async (e: FormEvent) => {
e.preventDefault();
try {
await createPost({ title, body, userId: 1 }).unwrap();
setTitle('');
setBody('');
alert('記事を作成しました!');
} catch (err) {
console.error('作成に失敗:', err);
}
};
return (
<form onSubmit={handleSubmit}>
<input
value={title}
onChange={(e) => setTitle(e.target.value)}
placeholder="タイトル"
/>
<textarea
value={body}
onChange={(e) => setBody(e.target.value)}
placeholder="本文"
/>
<button type="submit" disabled={isLoading}>
{isLoading ? '作成中...' : '投稿する'}
</button>
{isError && <p>エラーが発生しました</p>}
</form>
);
}
unwrap()メソッド
unwrap()はミューテーションの結果をPromiseとして扱い、成功時はデータを、失敗時はエラーをthrowします。
// unwrap()を使わない場合
const result = await createPost(newPost);
if ('data' in result) {
console.log('成功:', result.data);
} else {
console.log('エラー:', result.error);
}
// unwrap()を使う場合(try/catchで扱える)
try {
const data = await createPost(newPost).unwrap();
console.log('成功:', data);
} catch (error) {
console.log('エラー:', error);
}
実践例:完全なCRUDアプリケーション
ここまでの知識を組み合わせて、記事のCRUD操作を行うアプリケーションを構築しましょう。
APIスライスの定義
// features/api/postsApi.js
import { createApi, fetchBaseQuery } from '@reduxjs/toolkit/query/react';
export const postsApi = createApi({
reducerPath: 'postsApi',
baseQuery: fetchBaseQuery({
baseUrl: 'https://jsonplaceholder.typicode.com',
}),
endpoints: (builder) => ({
getPosts: builder.query({
query: () => '/posts',
}),
getPostById: builder.query({
query: (id) => `/posts/${id}`,
}),
createPost: builder.mutation({
query: (newPost) => ({
url: '/posts',
method: 'POST',
body: newPost,
}),
}),
updatePost: builder.mutation({
query: ({ id, ...patch }) => ({
url: `/posts/${id}`,
method: 'PUT',
body: patch,
}),
}),
deletePost: builder.mutation({
query: (id) => ({
url: `/posts/${id}`,
method: 'DELETE',
}),
}),
}),
});
export const {
useGetPostsQuery,
useGetPostByIdQuery,
useCreatePostMutation,
useUpdatePostMutation,
useDeletePostMutation,
} = postsApi;
記事一覧コンポーネント
function PostList() {
const { data: posts, isLoading, isError, refetch } = useGetPostsQuery();
const [deletePost] = useDeletePostMutation();
const handleDelete = async (id) => {
if (window.confirm('本当に削除しますか?')) {
try {
await deletePost(id).unwrap();
alert('削除しました');
} catch (err) {
alert('削除に失敗しました');
}
}
};
if (isLoading) return <div>読み込み中...</div>;
if (isError) return <div>エラーが発生しました</div>;
return (
<div>
<h2>記事一覧</h2>
<button onClick={refetch}>再読み込み</button>
{posts?.map((post) => (
<div key={post.id} style={{ border: '1px solid #ccc', padding: '16px', margin: '8px 0' }}>
<h3>{post.title}</h3>
<p>{post.body}</p>
<button onClick={() => handleDelete(post.id)}>削除</button>
</div>
))}
</div>
);
}
記事詳細コンポーネント
function PostDetail({ postId }) {
const { data: post, isLoading, isError } = useGetPostByIdQuery(postId);
const [updatePost, { isLoading: isUpdating }] = useUpdatePostMutation();
const handleUpdate = async () => {
try {
await updatePost({
id: postId,
title: post.title + ' (更新済み)',
}).unwrap();
alert('更新しました');
} catch (err) {
alert('更新に失敗しました');
}
};
if (isLoading) return <div>読み込み中...</div>;
if (isError) return <div>記事が見つかりません</div>;
return (
<div>
<h2>{post.title}</h2>
<p>{post.body}</p>
<button onClick={handleUpdate} disabled={isUpdating}>
{isUpdating ? '更新中...' : 'タイトルを更新'}
</button>
</div>
);
}
条件付きクエリ:skipオプション
特定の条件でクエリをスキップしたい場合はskipオプションを使います。
function PostDetail({ postId }) {
// postIdが未定義の場合、クエリをスキップ
const { data, isLoading } = useGetPostByIdQuery(postId, {
skip: !postId,
});
if (!postId) return <p>記事を選択してください</p>;
if (isLoading) return <p>読み込み中...</p>;
return <div>{data?.title}</div>;
}
createAsyncThunk vs RTK Query 比較
| 項目 | createAsyncThunk | RTK Query |
|---|---|---|
| ボイラープレート | 多い | 少ない |
| キャッシュ管理 | 手動 | 自動 |
| ローディング状態 | 手動管理 | 自動管理 |
| リクエスト重複排除 | なし | 自動 |
| 再取得 | 手動実装 | refetch()で簡単 |
| データ正規化 | 手動 | 自動(オプション) |
| TypeScript型 | 手動定義 | 自動推論 |
| 学習コスト | 低い | 中程度 |
| 適した用途 | 非API処理含む汎用ロジック | API通信に特化 |
まとめ
今日はRTK Queryの基本を学びました。
| 概念 | 説明 |
|---|---|
createApi |
APIスライスを作成する関数 |
fetchBaseQuery |
fetchベースのHTTPクライアント |
builder.query |
GETリクエスト用エンドポイント |
builder.mutation |
POST/PUT/DELETE用エンドポイント |
| 自動生成フック | useXQuery、useXMutation |
| 自動キャッシュ | 同一リクエストの重複排除 |
isLoading |
初回ロード中の状態 |
isFetching |
再取得中を含むローディング状態 |
unwrap() |
Promise形式でエラーハンドリング |
skip |
条件付きクエリ実行 |
練習問題
-
基本問題:
https://jsonplaceholder.typicode.com/usersを使って、ユーザー一覧を取得するAPIスライスを作成してください。useGetUsersQueryフックを使ってユーザー一覧を表示するコンポーネントも作成しましょう。 -
応用問題: コメント機能のCRUD APIを作成してください。エンドポイントは以下の通りです。
GET /posts/:postId/comments- 記事のコメント一覧POST /comments- コメントの作成DELETE /comments/:id- コメントの削除
-
チャレンジ問題:
skipオプションを使い、ドロップダウンでユーザーを選択すると、そのユーザーの投稿一覧を表示するコンポーネントを作成してください。ユーザーが未選択の場合はリクエストを送信しないようにしましょう。