10日で覚えるReduxDay 5: RTK Queryの基本

Day 5: RTK Queryの基本

今日学ぶこと

  • RTK Queryとは何か、なぜ手動データ取得を置き換えるのか
  • createApifetchBaseQueryのセットアップ
  • クエリエンドポイント(GET操作)の定義
  • ミューテーションエンドポイント(POST/PUT/DELETE)の定義
  • 自動生成フック:useGetXQueryuseXMutation
  • 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の設定オプション

fetchBaseQueryfetch 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用エンドポイント
自動生成フック useXQueryuseXMutation
自動キャッシュ 同一リクエストの重複排除
isLoading 初回ロード中の状態
isFetching 再取得中を含むローディング状態
unwrap() Promise形式でエラーハンドリング
skip 条件付きクエリ実行

練習問題

  1. 基本問題: https://jsonplaceholder.typicode.com/usersを使って、ユーザー一覧を取得するAPIスライスを作成してください。useGetUsersQueryフックを使ってユーザー一覧を表示するコンポーネントも作成しましょう。

  2. 応用問題: コメント機能のCRUD APIを作成してください。エンドポイントは以下の通りです。

    • GET /posts/:postId/comments - 記事のコメント一覧
    • POST /comments - コメントの作成
    • DELETE /comments/:id - コメントの削除
  3. チャレンジ問題: skipオプションを使い、ドロップダウンでユーザーを選択すると、そのユーザーの投稿一覧を表示するコンポーネントを作成してください。ユーザーが未選択の場合はリクエストを送信しないようにしましょう。