10日で覚えるReduxDay 6: RTK Query応用

Day 6: RTK Query応用

今日学ぶこと

  • タグによるキャッシュ無効化(providesTagsinvalidatesTags
  • タグタイプとタグIDによる精密な無効化
  • onQueryStartedによる楽観的更新
  • 悲観的更新パターン
  • pollingIntervalによるポーリング
  • usePrefetchによるプリフェッチ
  • ページネーションパターン
  • transformResponseによるレスポンス変換
  • injectEndpointsによるコード分割

タグによるキャッシュ無効化

Day 5で学んだRTK Queryの自動キャッシュには一つ課題があります。データを変更(作成・更新・削除)した後、キャッシュされた一覧データが古いままになることです。タグシステムがこの問題を解決します。

sequenceDiagram
    participant UI as UIコンポーネント
    participant Cache as RTK Query Cache
    participant API as APIサーバー

    UI->>Cache: useGetPostsQuery()
    Cache->>API: GET /posts
    API-->>Cache: posts data
    Cache-->>UI: データ返却
    Note over Cache: タグ: ['Post'] を付与

    UI->>Cache: useCreatePostMutation()
    Cache->>API: POST /posts
    API-->>Cache: 新しいpost
    Note over Cache: タグ 'Post' を無効化

    Cache->>API: GET /posts (自動再取得)
    API-->>Cache: 更新されたposts data
    Cache-->>UI: 最新データ返却

基本的なタグ設定

import { createApi, fetchBaseQuery } from '@reduxjs/toolkit/query/react';

export const postsApi = createApi({
  reducerPath: 'postsApi',
  baseQuery: fetchBaseQuery({
    baseUrl: 'https://jsonplaceholder.typicode.com',
  }),
  // 使用するタグタイプを宣言
  tagTypes: ['Post'],
  endpoints: (builder) => ({
    getPosts: builder.query({
      query: () => '/posts',
      // このクエリが提供するタグ
      providesTags: ['Post'],
    }),

    getPostById: builder.query({
      query: (id) => `/posts/${id}`,
      // IDごとに個別のタグを提供
      providesTags: (result, error, id) => [{ type: 'Post', id }],
    }),

    createPost: builder.mutation({
      query: (newPost) => ({
        url: '/posts',
        method: 'POST',
        body: newPost,
      }),
      // このミューテーションが無効化するタグ
      invalidatesTags: ['Post'],
    }),

    updatePost: builder.mutation({
      query: ({ id, ...patch }) => ({
        url: `/posts/${id}`,
        method: 'PUT',
        body: patch,
      }),
      // 特定のIDのタグだけを無効化
      invalidatesTags: (result, error, { id }) => [{ type: 'Post', id }],
    }),

    deletePost: builder.mutation({
      query: (id) => ({
        url: `/posts/${id}`,
        method: 'DELETE',
      }),
      invalidatesTags: (result, error, id) => [{ type: 'Post', id }],
    }),
  }),
});
TypeScript版
interface Post {
  id: number;
  title: string;
  body: string;
  userId: number;
}

export const postsApi = createApi({
  reducerPath: 'postsApi',
  baseQuery: fetchBaseQuery({
    baseUrl: 'https://jsonplaceholder.typicode.com',
  }),
  tagTypes: ['Post'],
  endpoints: (builder) => ({
    getPosts: builder.query<Post[], void>({
      query: () => '/posts',
      providesTags: ['Post'],
    }),
    getPostById: builder.query<Post, number>({
      query: (id) => `/posts/${id}`,
      providesTags: (result, error, id) => [{ type: 'Post', id }],
    }),
    createPost: builder.mutation<Post, Omit<Post, 'id'>>({
      query: (newPost) => ({
        url: '/posts',
        method: 'POST',
        body: newPost,
      }),
      invalidatesTags: ['Post'],
    }),
    updatePost: builder.mutation<Post, Partial<Post> & Pick<Post, 'id'>>({
      query: ({ id, ...patch }) => ({
        url: `/posts/${id}`,
        method: 'PUT',
        body: patch,
      }),
      invalidatesTags: (result, error, { id }) => [{ type: 'Post', id }],
    }),
    deletePost: builder.mutation<void, number>({
      query: (id) => ({
        url: `/posts/${id}`,
        method: 'DELETE',
      }),
      invalidatesTags: (result, error, id) => [{ type: 'Post', id }],
    }),
  }),
});

タグIDによる精密な無効化

タグにIDを付与することで、一覧全体ではなく特定の項目だけを再取得できます。

flowchart TB
    subgraph Tags["タグの構造"]
        T1["{ type: 'Post', id: 'LIST' }"]
        T2["{ type: 'Post', id: 1 }"]
        T3["{ type: 'Post', id: 2 }"]
        T4["{ type: 'Post', id: 3 }"]
    end

    subgraph Invalidation["無効化パターン"]
        I1["'Post' を無効化<br/>→ すべてのPostタグが無効"]
        I2["{ type: 'Post', id: 1 } を無効化<br/>→ ID:1のみ再取得"]
        I3["{ type: 'Post', id: 'LIST' } を無効化<br/>→ 一覧のみ再取得"]
    end

    style Tags fill:#3b82f6,color:#fff
    style Invalidation fill:#f59e0b,color:#fff

高度なタグパターン

endpoints: (builder) => ({
  getPosts: builder.query({
    query: () => '/posts',
    // 各投稿のIDと一覧全体のタグを提供
    providesTags: (result) =>
      result
        ? [
            // 各投稿に個別のタグ
            ...result.map(({ id }) => ({ type: 'Post', id })),
            // 一覧全体のタグ
            { type: 'Post', id: 'LIST' },
          ]
        : [{ type: 'Post', id: 'LIST' }],
  }),

  createPost: builder.mutation({
    query: (newPost) => ({
      url: '/posts',
      method: 'POST',
      body: newPost,
    }),
    // 新規作成時は一覧を再取得(個別の投稿は不要)
    invalidatesTags: [{ type: 'Post', id: 'LIST' }],
  }),

  updatePost: builder.mutation({
    query: ({ id, ...patch }) => ({
      url: `/posts/${id}`,
      method: 'PUT',
      body: patch,
    }),
    // 更新時は該当IDのみ再取得
    invalidatesTags: (result, error, { id }) => [{ type: 'Post', id }],
  }),

  deletePost: builder.mutation({
    query: (id) => ({
      url: `/posts/${id}`,
      method: 'DELETE',
    }),
    // 削除時は一覧と該当IDを無効化
    invalidatesTags: (result, error, id) => [
      { type: 'Post', id },
      { type: 'Post', id: 'LIST' },
    ],
  }),
}),
TypeScript版
endpoints: (builder) => ({
  getPosts: builder.query<Post[], void>({
    query: () => '/posts',
    providesTags: (result) =>
      result
        ? [
            ...result.map(({ id }) => ({ type: 'Post' as const, id })),
            { type: 'Post' as const, id: 'LIST' },
          ]
        : [{ type: 'Post' as const, id: 'LIST' }],
  }),

  createPost: builder.mutation<Post, Omit<Post, 'id'>>({
    query: (newPost) => ({
      url: '/posts',
      method: 'POST',
      body: newPost,
    }),
    invalidatesTags: [{ type: 'Post', id: 'LIST' }],
  }),

  updatePost: builder.mutation<Post, Partial<Post> & Pick<Post, 'id'>>({
    query: ({ id, ...patch }) => ({
      url: `/posts/${id}`,
      method: 'PUT',
      body: patch,
    }),
    invalidatesTags: (result, error, { id }) => [{ type: 'Post', id }],
  }),

  deletePost: builder.mutation<void, number>({
    query: (id) => ({
      url: `/posts/${id}`,
      method: 'DELETE',
    }),
    invalidatesTags: (result, error, id) => [
      { type: 'Post', id },
      { type: 'Post', id: 'LIST' },
    ],
  }),
}),

複数のタグタイプ

const api = createApi({
  baseQuery: fetchBaseQuery({ baseUrl: '/api' }),
  tagTypes: ['Post', 'Comment', 'User'],
  endpoints: (builder) => ({
    getPostWithComments: builder.query({
      query: (postId) => `/posts/${postId}?_embed=comments`,
      // 複数のタグタイプを提供
      providesTags: (result, error, postId) => [
        { type: 'Post', id: postId },
        { type: 'Comment', id: `POST_${postId}` },
      ],
    }),

    addComment: builder.mutation({
      query: ({ postId, ...comment }) => ({
        url: '/comments',
        method: 'POST',
        body: { postId, ...comment },
      }),
      // コメント追加時に関連する投稿のコメントも無効化
      invalidatesTags: (result, error, { postId }) => [
        { type: 'Comment', id: `POST_${postId}` },
      ],
    }),
  }),
});

楽観的更新(Optimistic Updates)

楽観的更新は、サーバーの応答を待たずにUIを即座に更新するテクニックです。ユーザー体験が大幅に向上します。

sequenceDiagram
    participant UI as UI
    participant Cache as Cache
    participant API as API

    UI->>Cache: Todo完了をトグル
    Cache->>UI: 即座にUIを更新(楽観的)
    Cache->>API: PUT /todos/1

    alt 成功
        API-->>Cache: 200 OK
        Note over Cache: キャッシュはすでに最新
    else 失敗
        API-->>Cache: エラー
        Cache->>UI: 元の状態に戻す(ロールバック)
    end

楽観的更新の実装

const api = createApi({
  baseQuery: fetchBaseQuery({ baseUrl: '/api' }),
  tagTypes: ['Todo'],
  endpoints: (builder) => ({
    getTodos: builder.query({
      query: () => '/todos',
      providesTags: (result) =>
        result
          ? [
              ...result.map(({ id }) => ({ type: 'Todo', id })),
              { type: 'Todo', id: 'LIST' },
            ]
          : [{ type: 'Todo', id: 'LIST' }],
    }),

    toggleTodo: builder.mutation({
      query: ({ id, completed }) => ({
        url: `/todos/${id}`,
        method: 'PATCH',
        body: { completed },
      }),

      // 楽観的更新
      async onQueryStarted({ id, completed }, { dispatch, queryFulfilled }) {
        // 1. キャッシュを即座に更新
        const patchResult = dispatch(
          api.util.updateQueryData('getTodos', undefined, (draft) => {
            const todo = draft.find((t) => t.id === id);
            if (todo) {
              todo.completed = completed;
            }
          })
        );

        try {
          // 2. サーバーの応答を待つ
          await queryFulfilled;
        } catch {
          // 3. エラー時はロールバック
          patchResult.undo();
        }
      },
    }),
  }),
});
TypeScript版
interface Todo {
  id: number;
  title: string;
  completed: boolean;
  userId: number;
}

const api = createApi({
  baseQuery: fetchBaseQuery({ baseUrl: '/api' }),
  tagTypes: ['Todo'],
  endpoints: (builder) => ({
    getTodos: builder.query<Todo[], void>({
      query: () => '/todos',
      providesTags: (result) =>
        result
          ? [
              ...result.map(({ id }) => ({ type: 'Todo' as const, id })),
              { type: 'Todo' as const, id: 'LIST' },
            ]
          : [{ type: 'Todo' as const, id: 'LIST' }],
    }),

    toggleTodo: builder.mutation<Todo, { id: number; completed: boolean }>({
      query: ({ id, completed }) => ({
        url: `/todos/${id}`,
        method: 'PATCH',
        body: { completed },
      }),
      async onQueryStarted({ id, completed }, { dispatch, queryFulfilled }) {
        const patchResult = dispatch(
          api.util.updateQueryData('getTodos', undefined, (draft) => {
            const todo = draft.find((t) => t.id === id);
            if (todo) {
              todo.completed = completed;
            }
          })
        );
        try {
          await queryFulfilled;
        } catch {
          patchResult.undo();
        }
      },
    }),
  }),
});

楽観的更新を使うTodoコンポーネント

function TodoList() {
  const { data: todos } = useGetTodosQuery();
  const [toggleTodo] = useToggleTodoMutation();

  return (
    <ul>
      {todos?.map((todo) => (
        <li
          key={todo.id}
          style={{
            textDecoration: todo.completed ? 'line-through' : 'none',
            cursor: 'pointer',
          }}
          onClick={() =>
            toggleTodo({ id: todo.id, completed: !todo.completed })
          }
        >
          {todo.title}
        </li>
      ))}
    </ul>
  );
}

悲観的更新

悲観的更新は、サーバーの応答を受け取ってからキャッシュを更新するパターンです。データの整合性が重要な場合に使います。

const api = createApi({
  baseQuery: fetchBaseQuery({ baseUrl: '/api' }),
  tagTypes: ['Post'],
  endpoints: (builder) => ({
    getPosts: builder.query({
      query: () => '/posts',
      providesTags: ['Post'],
    }),

    createPost: builder.mutation({
      query: (newPost) => ({
        url: '/posts',
        method: 'POST',
        body: newPost,
      }),

      // 悲観的更新: サーバー応答後にキャッシュを更新
      async onQueryStarted(newPost, { dispatch, queryFulfilled }) {
        try {
          const { data: createdPost } = await queryFulfilled;
          // サーバーから返されたデータでキャッシュを更新
          dispatch(
            api.util.updateQueryData('getPosts', undefined, (draft) => {
              draft.push(createdPost);
            })
          );
        } catch {
          // エラー時は何もしない(キャッシュは変更されていない)
        }
      },
    }),
  }),
});
TypeScript版
const api = createApi({
  baseQuery: fetchBaseQuery({ baseUrl: '/api' }),
  tagTypes: ['Post'],
  endpoints: (builder) => ({
    getPosts: builder.query<Post[], void>({
      query: () => '/posts',
      providesTags: ['Post'],
    }),

    createPost: builder.mutation<Post, Omit<Post, 'id'>>({
      query: (newPost) => ({
        url: '/posts',
        method: 'POST',
        body: newPost,
      }),
      async onQueryStarted(newPost, { dispatch, queryFulfilled }) {
        try {
          const { data: createdPost } = await queryFulfilled;
          dispatch(
            api.util.updateQueryData('getPosts', undefined, (draft) => {
              draft.push(createdPost);
            })
          );
        } catch {
          // No rollback needed
        }
      },
    }),
  }),
});

楽観的 vs 悲観的更新の使い分け

方式 UI応答速度 データ整合性 適した場面
楽観的更新 高速(即座) リスクあり いいね、トグル、並べ替え
悲観的更新 遅い(応答待ち) 安全 決済、重要データの作成
タグ無効化 遅い(再取得) 安全 汎用的なCRUD

ポーリング

pollingIntervalを使うと、一定間隔でデータを自動再取得できます。リアルタイムダッシュボードなどに有用です。

function LiveDashboard() {
  // 30秒ごとにデータを自動更新
  const { data: stats, isLoading } = useGetDashboardStatsQuery(undefined, {
    pollingInterval: 30000, // 30秒
  });

  if (isLoading) return <div>読み込み中...</div>;

  return (
    <div>
      <h2>ダッシュボード</h2>
      <div>アクティブユーザー: {stats?.activeUsers}</div>
      <div>本日の売上: ¥{stats?.todaySales?.toLocaleString()}</div>
      <div>注文数: {stats?.orderCount}</div>
    </div>
  );
}

ポーリングの制御

function PollingControl() {
  const [pollingInterval, setPollingInterval] = useState(0);

  const { data } = useGetNotificationsQuery(undefined, {
    // 0を渡すとポーリング停止
    pollingInterval,
    // ウィンドウがフォーカスされていない時はスキップ
    skipPollingIfUnfocused: true,
  });

  return (
    <div>
      <button onClick={() => setPollingInterval(5000)}>5秒間隔で更新</button>
      <button onClick={() => setPollingInterval(30000)}>30秒間隔で更新</button>
      <button onClick={() => setPollingInterval(0)}>ポーリング停止</button>
    </div>
  );
}

リアルタイムダッシュボードの完全な例

// features/api/dashboardApi.js
const dashboardApi = createApi({
  reducerPath: 'dashboardApi',
  baseQuery: fetchBaseQuery({ baseUrl: '/api' }),
  tagTypes: ['Stats'],
  endpoints: (builder) => ({
    getDashboardStats: builder.query({
      query: () => '/dashboard/stats',
      providesTags: ['Stats'],
    }),

    getRecentOrders: builder.query({
      query: () => '/dashboard/orders/recent',
      providesTags: ['Stats'],
    }),

    getAlerts: builder.query({
      query: () => '/dashboard/alerts',
    }),
  }),
});

export const {
  useGetDashboardStatsQuery,
  useGetRecentOrdersQuery,
  useGetAlertsQuery,
} = dashboardApi;

// Dashboard.jsx
function Dashboard() {
  const { data: stats } = useGetDashboardStatsQuery(undefined, {
    pollingInterval: 30000,
  });

  const { data: orders } = useGetRecentOrdersQuery(undefined, {
    pollingInterval: 10000,
  });

  const { data: alerts } = useGetAlertsQuery(undefined, {
    pollingInterval: 5000,
  });

  return (
    <div>
      <StatsCards stats={stats} />
      <OrderTable orders={orders} />
      <AlertBanner alerts={alerts} />
    </div>
  );
}

プリフェッチ

usePrefetchを使うと、ユーザーが操作する前にデータを事前に取得できます。

function PostList() {
  const { data: posts } = useGetPostsQuery();
  // プリフェッチ関数を取得
  const prefetchPost = usePrefetch('getPostById');

  return (
    <ul>
      {posts?.map((post) => (
        <li
          key={post.id}
          // マウスホバー時にプリフェッチ
          onMouseEnter={() => prefetchPost(post.id)}
        >
          <Link to={`/posts/${post.id}`}>{post.title}</Link>
        </li>
      ))}
    </ul>
  );
}

プリフェッチのオプション

function PostList() {
  const prefetchPost = usePrefetch('getPostById', {
    // データが60秒以上古い場合のみプリフェッチ
    ifOlderThan: 60,
  });

  // または force オプションで常にプリフェッチ
  const prefetchPostForce = usePrefetch('getPostById', {
    force: true,
  });

  return (
    <ul>
      {posts?.map((post) => (
        <li
          key={post.id}
          onMouseEnter={() => prefetchPost(post.id)}
          onFocus={() => prefetchPost(post.id)}
        >
          <Link to={`/posts/${post.id}`}>{post.title}</Link>
        </li>
      ))}
    </ul>
  );
}

ページネーション

RTK Queryでページネーションを実装するパターンを紹介します。

const api = createApi({
  baseQuery: fetchBaseQuery({ baseUrl: '/api' }),
  tagTypes: ['Post'],
  endpoints: (builder) => ({
    getPosts: builder.query({
      query: ({ page = 1, limit = 10 }) =>
        `/posts?_page=${page}&_limit=${limit}`,
      providesTags: (result) =>
        result
          ? [
              ...result.map(({ id }) => ({ type: 'Post', id })),
              { type: 'Post', id: 'PARTIAL-LIST' },
            ]
          : [{ type: 'Post', id: 'PARTIAL-LIST' }],
    }),
  }),
});

ページネーションコンポーネント

function PaginatedPosts() {
  const [page, setPage] = useState(1);
  const { data: posts, isLoading, isFetching } = useGetPostsQuery({
    page,
    limit: 10,
  });
  const prefetchPosts = usePrefetch('getPosts');

  return (
    <div>
      {isLoading ? (
        <div>読み込み中...</div>
      ) : (
        <div style={{ opacity: isFetching ? 0.5 : 1 }}>
          {posts?.map((post) => (
            <div key={post.id}>
              <h3>{post.title}</h3>
              <p>{post.body}</p>
            </div>
          ))}
        </div>
      )}

      <div>
        <button
          onClick={() => setPage((p) => Math.max(1, p - 1))}
          disabled={page === 1}
        >
          前のページ
        </button>

        <span>ページ {page}</span>

        <button
          onClick={() => setPage((p) => p + 1)}
          // 次のページをプリフェッチ
          onMouseEnter={() =>
            prefetchPosts({ page: page + 1, limit: 10 })
          }
        >
          次のページ
        </button>
      </div>
    </div>
  );
}

transformResponse

サーバーのレスポンスを変換してからキャッシュに保存できます。

const api = createApi({
  baseQuery: fetchBaseQuery({ baseUrl: '/api' }),
  endpoints: (builder) => ({
    // レスポンスから必要なデータだけ抽出
    getUsers: builder.query({
      query: () => '/users',
      transformResponse: (response) =>
        response.map((user) => ({
          id: user.id,
          name: user.name,
          email: user.email,
        })),
    }),

    // ネストされたデータを平坦化
    getPostComments: builder.query({
      query: (postId) => `/posts/${postId}/comments`,
      transformResponse: (response) =>
        response.map((comment) => ({
          ...comment,
          shortBody: comment.body.substring(0, 100),
        })),
    }),

    // ソートや正規化
    getSortedPosts: builder.query({
      query: () => '/posts',
      transformResponse: (response) =>
        [...response].sort((a, b) =>
          a.title.localeCompare(b.title)
        ),
    }),
  }),
});
TypeScript版
interface UserRaw {
  id: number;
  name: string;
  email: string;
  phone: string;
  website: string;
  company: { name: string };
  address: { city: string };
}

interface UserSimple {
  id: number;
  name: string;
  email: string;
}

const api = createApi({
  baseQuery: fetchBaseQuery({ baseUrl: '/api' }),
  endpoints: (builder) => ({
    getUsers: builder.query<UserSimple[], void>({
      query: () => '/users',
      transformResponse: (response: UserRaw[]) =>
        response.map((user) => ({
          id: user.id,
          name: user.name,
          email: user.email,
        })),
    }),
  }),
});

カスタムbaseQuery

認証トークンの自動付与やエラーハンドリングの共通化にカスタムbaseQueryを使います。

import { fetchBaseQuery } from '@reduxjs/toolkit/query/react';

// トークンリフレッシュ付きのbaseQuery
const baseQuery = fetchBaseQuery({
  baseUrl: '/api',
  prepareHeaders: (headers, { getState }) => {
    const token = getState().auth.accessToken;
    if (token) {
      headers.set('Authorization', `Bearer ${token}`);
    }
    return headers;
  },
});

const baseQueryWithReauth = async (args, api, extraOptions) => {
  let result = await baseQuery(args, api, extraOptions);

  if (result?.error?.status === 401) {
    // トークンをリフレッシュ
    const refreshResult = await baseQuery(
      { url: '/auth/refresh', method: 'POST' },
      api,
      extraOptions
    );

    if (refreshResult?.data) {
      // 新しいトークンを保存
      api.dispatch(setCredentials(refreshResult.data));
      // 元のリクエストを再試行
      result = await baseQuery(args, api, extraOptions);
    } else {
      // リフレッシュ失敗 → ログアウト
      api.dispatch(logout());
    }
  }

  return result;
};

export const api = createApi({
  baseQuery: baseQueryWithReauth,
  tagTypes: ['Post', 'User'],
  endpoints: () => ({}),
});
TypeScript版
import {
  fetchBaseQuery,
  BaseQueryFn,
  FetchArgs,
  FetchBaseQueryError,
} from '@reduxjs/toolkit/query/react';
import { RootState } from '../../store';
import { setCredentials, logout } from '../auth/authSlice';

const baseQuery = fetchBaseQuery({
  baseUrl: '/api',
  prepareHeaders: (headers, { getState }) => {
    const token = (getState() as RootState).auth.accessToken;
    if (token) {
      headers.set('Authorization', `Bearer ${token}`);
    }
    return headers;
  },
});

const baseQueryWithReauth: BaseQueryFn<
  string | FetchArgs,
  unknown,
  FetchBaseQueryError
> = async (args, api, extraOptions) => {
  let result = await baseQuery(args, api, extraOptions);

  if (result?.error?.status === 401) {
    const refreshResult = await baseQuery(
      { url: '/auth/refresh', method: 'POST' },
      api,
      extraOptions
    );

    if (refreshResult?.data) {
      api.dispatch(setCredentials(refreshResult.data as { accessToken: string }));
      result = await baseQuery(args, api, extraOptions);
    } else {
      api.dispatch(logout());
    }
  }

  return result;
};

export const api = createApi({
  baseQuery: baseQueryWithReauth,
  tagTypes: ['Post', 'User'],
  endpoints: () => ({}),
});

injectEndpointsによるコード分割

大規模なアプリケーションでは、すべてのエンドポイントを1つのファイルに定義するのは実用的ではありません。injectEndpointsを使ってエンドポイントを分割できます。

// features/api/baseApi.js - 共通のAPI定義
import { createApi, fetchBaseQuery } from '@reduxjs/toolkit/query/react';

export const baseApi = createApi({
  reducerPath: 'api',
  baseQuery: fetchBaseQuery({ baseUrl: '/api' }),
  tagTypes: ['Post', 'User', 'Comment'],
  endpoints: () => ({}),
});
// features/api/postsApi.js - 投稿エンドポイント
import { baseApi } from './baseApi';

export const postsApi = baseApi.injectEndpoints({
  endpoints: (builder) => ({
    getPosts: builder.query({
      query: () => '/posts',
      providesTags: ['Post'],
    }),
    createPost: builder.mutation({
      query: (newPost) => ({
        url: '/posts',
        method: 'POST',
        body: newPost,
      }),
      invalidatesTags: ['Post'],
    }),
  }),
});

export const { useGetPostsQuery, useCreatePostMutation } = postsApi;
// features/api/usersApi.js - ユーザーエンドポイント
import { baseApi } from './baseApi';

export const usersApi = baseApi.injectEndpoints({
  endpoints: (builder) => ({
    getUsers: builder.query({
      query: () => '/users',
      providesTags: ['User'],
    }),
    getUserById: builder.query({
      query: (id) => `/users/${id}`,
      providesTags: (result, error, id) => [{ type: 'User', id }],
    }),
  }),
});

export const { useGetUsersQuery, useGetUserByIdQuery } = usersApi;

ストアの設定はbaseApiだけ

// store.js
import { configureStore } from '@reduxjs/toolkit';
import { baseApi } from './features/api/baseApi';

export const store = configureStore({
  reducer: {
    [baseApi.reducerPath]: baseApi.reducer,
  },
  middleware: (getDefaultMiddleware) =>
    getDefaultMiddleware().concat(baseApi.middleware),
});

まとめ

今日はRTK Queryの応用テクニックを学びました。

概念 説明
providesTags クエリが提供するキャッシュタグ
invalidatesTags ミューテーションが無効化するタグ
タグID 精密なキャッシュ無効化のためのID
楽観的更新 サーバー応答前にUIを即座に更新
悲観的更新 サーバー応答後にキャッシュを更新
pollingInterval 定期的な自動データ再取得
usePrefetch データの事前取得
transformResponse レスポンスの変換処理
カスタムbaseQuery 認証やエラーの共通処理
injectEndpoints エンドポイントのコード分割

練習問題

  1. 基本問題: 投稿とコメントの2つのタグタイプを使い、コメントを追加したときに該当する投稿のコメント一覧だけが再取得されるAPIスライスを作成してください。

  2. 応用問題: Todoアプリで楽観的更新を実装してください。Todoのタイトル編集時にUIを即座に更新し、サーバーエラー時にはロールバックするようにしましょう。

  3. チャレンジ問題: 認証トークンの自動リフレッシュを行うカスタムbaseQueryを実装してください。401エラー時にリフレッシュトークンで新しいアクセストークンを取得し、元のリクエストを再試行する仕組みを作りましょう。injectEndpointsを使ってエンドポイントをファイル分割することも試してみてください。