メインコンテンツへスキップ

Redux Essentials, パート 7: RTK Query の基礎

学習内容
  • RTK Query が Redux アプリケーションのデータフェッチをどのように簡素化するのか
  • RTK Query をセットアップする方法
  • 基本的なデータフェッチと更新リクエストに RTK Query を使用する方法
前提条件
  • Redux Toolkit の使用パターンを理解するために、このチュートリアルの前のセクションを完了していること。
ビデオコースをご希望ですか?

ビデオコースをご希望の場合は、RTK Query の作成者である Lenz Weber-Tronic 氏による RTK Query ビデオコースを Egghead で無料で視聴するか、最初のレッスンをここでご覧ください。

はじめに

パート 5: 非同期ロジックとデータフェッチ および パート 6: パフォーマンスとデータの正規化 では、Redux でのデータフェッチとキャッシュに使用される標準的なパターンを学びました。これらのパターンには、非同期サンクを使用してデータを取得し、結果とともにアクションをディスパッチし、ストア内のリクエスト読み込み状態を管理し、ID ごとに個々のアイテムを簡単に検索および更新できるようにキャッシュデータを正規化することが含まれます。

このセクションでは、Redux アプリケーション向けに設計されたデータフェッチおよびキャッシュソリューションである RTK Query を使用する方法と、データを取得してコンポーネントで使用するためのプロセスを簡素化する方法について説明します。

RTK Query の概要

**RTK Query** は、強力なデータフェッチおよびキャッシュツールです。Web アプリケーションでデータを読み込む一般的なケースを簡素化し、**データフェッチとキャッシュロジックを手動で記述する必要性をなくす**ように設計されています。

RTK Query は、**Redux Toolkit パッケージに含まれるオプションのアドオン**であり、その機能は Redux Toolkit の他の API の上に構築されています。

動機

Web アプリケーションは通常、表示するためにサーバーからデータを取得する必要があります。また、通常は、そのデータを更新し、更新をサーバーに送信し、クライアント上のキャッシュデータをサーバー上のデータと同期させる必要があります。これは、今日のアプリケーションで使用される他の動作を実装する必要があるため、より複雑になります。

  • UIスピナーを表示するための読み込み状態の追跡
  • 同じデータに対する重複リクエストの回避
  • UIを高速に感じさせるための楽観的更新
  • ユーザーが UI を操作する際のキャッシュの有効期限の管理

Redux Toolkit を使用してこれらの動作を実装する方法については、既に説明しました。

ただし、歴史的に Redux には、これらのユースケースを*完全に*解決するのに役立つ組み込みのものが含まれていませんでした。 `createAsyncThunk` を `createSlice` と一緒に使用する場合でも、リクエストの作成と読み込み状態の管理にはかなりの手作業が必要です。非同期サンクを作成し、実際のリクエストを行い、レスポンスから関連フィールドを取得し、読み込み状態フィールドを追加し、`pending/fulfilled/rejected` ケースを処理するために `extraReducers` にハンドラーを追加し、実際に適切な状態更新を記述する必要があります。

ここ数年で、React コミュニティは、**「データフェッチとキャッシュ」は「状態管理」とは実際には異なる懸念事項のセットである**ことに気づきました。Redux のような状態管理ライブラリを使用してデータをキャッシュできますが、ユースケースが十分に異なるため、データフェッチのユースケース向けに特別に構築されたツールを使用する価値があります。

RTK Query は、Apollo Client、React Query、Urql、SWR など、データフェッチのソリューションを開拓してきた他のツールからインスピレーションを得ていますが、API 設計に独自のアプローチを追加しています。

  • データフェッチとキャッシュロジックは、Redux Toolkit の `createSlice` および `createAsyncThunk` API の上に構築されています。
  • Redux Toolkit は UI に依存しないため、RTK Query の機能はあらゆる UI レイヤーで使用できます。
  • API エンドポイントは、引数からクエリパラメータを生成し、キャッシュのためにレスポンスを変換する方法の構成を含めて、事前に定義されます。
  • RTK Query は、データフェッチプロセス全体をカプセル化し、コンポーネントに `data` および `isFetching` フィールドを提供し、コンポーネントのマウントとアンマウント時のキャッシュデータの有効期限を管理する React フックも生成できます。
  • RTK Query は、初期データのフェッチ後に websocket メッセージを介したストリーミングキャッシュ更新などのユースケースを可能にする「キャッシュエントリのライフサイクル」オプションを提供します。
  • OpenAPI および GraphQL スキーマからの API スライスのコード生成の初期段階の作業例があります。
  • 最後に、RTK Query は TypeScript で完全に記述されており、優れた TS 使用エクスペリエンスを提供するように設計されています。

含まれるもの

API

RTK Query は、コア Redux Toolkit パッケージのインストールに含まれています。以下の 2 つのエントリポイントのいずれかを介して利用できます。

import { createApi } from '@reduxjs/toolkit/query'

/* React-specific entry point that automatically generates
hooks corresponding to the defined endpoints */
import { createApi } from '@reduxjs/toolkit/query/react'

RTK Query は、主に 2 つの API で構成されています。

  • `createApi()`: RTK Query 機能の中核です。データのフェッチ方法と変換方法の構成を含め、一連のエンドポイントからデータを取得する方法を記述するエンドポイントのセットを定義できます。ほとんどの場合、アプリごとに 1 回使用し、原則として「ベース URL ごとに 1 つの API スライス」を使用する必要があります。
  • `fetchBaseQuery()`: リクエストを簡素化することを目的とした、`fetch` の小さなラッパーです。ほとんどのユーザーが `createApi` で使用する `baseQuery` として推奨されています。

バンドルサイズ

RTK Query は、アプリのバンドルサイズに固定の 1 回限りの量を追加します。RTK Query は Redux Toolkit と React-Redux の上に構築されているため、追加されるサイズは、アプリで既にそれらを使用しているかどうかによって異なります。推定最小 + gzip バンドルサイズは次のとおりです。

  • RTK を既に使用している場合: RTK Query の場合は約 9kb、フックの場合は約 2kb。
  • RTK をまだ使用していない場合
    • React なし: RTK + 依存関係 + RTK Query の場合は 17 kB
    • React あり: 19kB + React-Redux(これはピア依存関係です)

エンドポイント定義を追加しても、`endpoints` 定義内の実際のコードに基づいてサイズが増加するだけで、通常は数バイトです。

RTK Query に含まれる機能は、追加されたバンドルサイズをすぐに補い、手書きのデータフェッチロジックを排除することで、ほとんどの有意義なアプリケーションのサイズが正味改善されるはずです。

RTK Query キャッシュの考え方

Redux は常に、予測可能性と明示的な動作を重視してきました。Redux には「魔法」は含まれていません。**すべての Redux ロジックは、アクションをディスパッチし、レデューサーを介して状態を更新するという同じ基本パターンに従う**ため、アプリケーションで何が起こっているかを理解できるはずです。これは、物事を起こすためにより多くのコードを記述する必要がある場合があることを意味しますが、トレードオフは、データフローと動作が非常に明確になることです。

**Redux Toolkit コア API は、Redux アプリの基本的なデータフローを一切変更しません**。アクションをディスパッチし、レデューサーを記述していますが、すべてのロジックを手動で記述するよりもコードが少なくなっています。**RTK Query も同様です**。これは追加の抽象化レイヤーですが、**内部的には、非同期リクエストとそのレスポンスの管理のために既に見てきたのとまったく同じ手順を実行しています**。

ただし、RTK Query を使用すると、*考え方*が変わります。もはや「状態の管理」そのものを考えているわけではありません。代わりに、**「*キャッシュデータ*の管理」について考えるようになりました**。自分でレデューサーを記述しようとするのではなく、**「このデータはどこから来ているのか?」、「この更新はどのように送信する必要があるのか?」、「このキャッシュデータはいつ再フェッチする必要があるのか?」、「キャッシュデータはどのように更新する必要があるのか?」**に焦点を当てるようになります。そのデータがどのようにフェッチ、格納、および取得されるかは、もはや心配する必要のない実装の詳細になります。

この考え方の変化がどのように適用されるかについては、引き続き説明します。

RTK Query のセットアップ

サンプルアプリケーションは既に動作していますが、 अबすべての非同期ロジックを RTK Query を使用するように移行します。進めていくうちに、RTK Query のすべての主要機能の使い方と、`createAsyncThunk` および `createSlice` の既存の使い方を RTK Query API を使用するように移行する方法について説明します。

API スライスの定義

以前は、投稿、ユーザー、通知など、さまざまなデータ型ごとに個別の「スライス」を定義しました。各スライスには独自のレデューサーがあり、独自のアクションとサンクを定義し、そのデータ型のエントリを個別にキャッシュしていました。

RTK Queryでは、**キャッシュデータ管理のロジックは、アプリケーションごとに単一の「APIスライス」に集約されます**。アプリごとに単一のReduxストアを持つ方法と同様に、_すべて_のキャッシュデータに対して単一のスライスを持つようになりました。

まず、新しい`apiSlice.js`ファイルを作成します。これは既に記述した他の「機能」に固有のものではないため、新しい`features/api/`フォルダを追加し、そこに`apiSlice.js`を配置します。APIスライスファイルの内容を記述し、そのコードが何をしているのかを分析してみましょう。

features/api/apiSlice.js
// Import the RTK Query methods from the React-specific entry point
import { createApi, fetchBaseQuery } from '@reduxjs/toolkit/query/react'

// Define our single API slice object
export const apiSlice = createApi({
// The cache reducer expects to be added at `state.api` (already default - this is optional)
reducerPath: 'api',
// All of our requests will have URLs starting with '/fakeApi'
baseQuery: fetchBaseQuery({ baseUrl: '/fakeApi' }),
// The "endpoints" represent operations and requests for this server
endpoints: builder => ({
// The `getPosts` endpoint is a "query" operation that returns data
getPosts: builder.query({
// The URL for the request is '/fakeApi/posts'
query: () => '/posts'
})
})
})

// Export the auto-generated hook for the `getPosts` query endpoint
export const { useGetPostsQuery } = apiSlice

RTK Queryの機能は、`createApi`と呼ばれる単一のメソッドに基づいています。これまでに見てきたRedux Toolkit APIはすべてUIに依存せず、_あらゆる_ UIレイヤーで使用できます。RTK Queryのコアロジックも同じです。ただし、RTK QueryにはReact固有のバージョンの`createApi`も含まれており、RTKとReactを一緒に使用しているため、RTKのReact統合を活用するにはそれを使用する必要があります。そのため、具体的には`'@reduxjs/toolkit/query/react'`からインポートします。

ヒント

**アプリケーションには`createApi`の呼び出しが1つだけ存在することが想定されています**。この1つのAPIスライスには、同じベースURLと通信する_すべて_のエンドポイント定義が含まれている必要があります。たとえば、エンドポイント`/api/posts`と`/api/users`はどちらも同じサーバーからデータを取得するため、同じAPIスライスに配置されます。アプリが複数のサーバーからデータを取得する場合は、各エンドポイントに完全なURLを指定するか、必要に応じてサーバーごとに個別のAPIスライスを作成できます。

エンドポイントは通常、`createApi`呼び出し内で直接定義されます。エンドポイントを複数のファイルに分割する場合は、ドキュメントのパート8の「エンドポイントの注入」セクションを参照してください。

APIスライスパラメータ

`createApi`を呼び出す際には、必須のフィールドが2つあります。

  • `baseQuery`:サーバーからデータを取得する方法を認識する関数。RTK Queryには、標準の`fetch()`関数をラップした小さなラッパーである`fetchBaseQuery`が含まれており、リクエストとレスポンスの一般的な処理を扱います。`fetchBaseQuery`インスタンスを作成すると、将来のリクエストすべてのベースURLを渡したり、リクエストヘッダーの変更などの動作をオーバーライドしたりできます。
  • `endpoints`:このサーバーとの対話のために定義した操作のセット。エンドポイントは、キャッシュするデータを返す**_クエリ_**、またはサーバーに更新を送信する**_ミューテーション_**のいずれかです。エンドポイントは、`builder`パラメータを受け取り、`builder.query()`および`builder.mutation()`で作成されたエンドポイント定義を含むオブジェクトを返すコールバック関数を使用して定義されます。

`createApi`は、生成されたレデューサーの予想されるトップレベルの状態スライスフィールドを定義する`reducerPath`フィールドも受け入れます。`postsSlice`のような他のスライスでは、`state.posts`を更新するために使用されるという保証はありません。ルート状態のどこにでもレデューサーをアタッチできます。たとえば、`someOtherField: postsReducer`のように。ここで、`createApi`は、キャッシュレデューサーをストアに追加するときにキャッシュ状態がどこにあるかを指示することを期待しています。`reducerPath`オプションを指定しないと、デフォルトで`'api'`になるため、すべてのRTKQキャッシュデータは`state.api`に格納されます。

ストアにレデューサーを追加するのを忘れた場合、または`reducerPath`で指定されたものとは異なるキーにアタッチした場合、RTKQはエラーをログに記録して、修正が必要であることを知らせます。

エンドポイントの定義

すべてのリクエストのURLの最初の部分は、`fetchBaseQuery`定義で`'/fakeApi'`として定義されています。

最初のステップとして、偽のAPIサーバーから投稿のリスト全体を返すエンドポイントを追加します。`getPosts`というエンドポイントを含め、`builder.query()`を使用して**クエリエンドポイント**として定義します。このメソッドは、リクエストの作成方法とレスポンスの処理方法を設定するための多くのオプションを受け入れます。今のところ、必要なのは、URL文字列を返すコールバックで`query`オプションを定義することで、URLパスの残りの部分を指定することだけです。`() => '/posts'`。

デフォルトでは、クエリエンドポイントは`GET` HTTPリクエストを使用しますが、URL文字列自体ではなく`{url: '/posts', method: 'POST', body: newPost}`のようなオブジェクトを返すことでオーバーライドできます。ヘッダーの設定など、この方法でリクエストの他のいくつかのオプションを定義することもできます。

APIスライスとフックのエクスポート

以前のスライスファイルでは、アクションクリエーターとスライスレデューサーのみをエクスポートしました。なぜなら、それが他のファイルで必要なすべてだからです。RTK Queryでは、通常、「APIスライス」オブジェクト全体をエクスポートします。なぜなら、役に立つフィールドがいくつかあるからです。

最後に、このファイルの最後の行をよく見てください。この`useGetPostsQuery`値はどこから来ているのでしょうか?

**RTK QueryのReact統合は、定義した_すべて_のエンドポイントに対してReactフックを自動的に生成します!** これらのフックは、コンポーネントがマウントされたときにリクエストをトリガーし、リクエストが処理されてデータが利用可能になるにつれてコンポーネントを再レンダリングするプロセスをカプセル化します。これらのフックをこのAPIスライスファイルからエクスポートして、Reactコンポーネントで使用できます。

フックは、標準の規則に基づいて自動的に名前が付けられます。

  • `use`、Reactフックの通常のプレフィックス
  • エンドポイントの名前、大文字
  • エンドポイントのタイプ、`Query`または`Mutation`

この場合、エンドポイントは`getPosts`であり、クエリエンドポイントであるため、生成されるフックは`useGetPostsQuery`です。

ストアの設定

次に、APIスライスをReduxストアに接続する必要があります。既存の`store.js`ファイルを修正して、APIスライスのキャッシュレデューサーを状態に追加できます。また、APIスライスはストアに追加する必要があるカスタムミドルウェアを生成します。このミドルウェアは_必ず_追加する必要があります。キャッシュの有効期間と期限切れを管理します。

app/store.js
import postsReducer from '../features/posts/postsSlice'
import usersReducer from '../features/users/usersSlice'
import notificationsReducer from '../features/notifications/notificationsSlice'
import { apiSlice } from '../features/api/apiSlice'

export default configureStore({
reducer: {
posts: postsReducer,
users: usersReducer,
notifications: notificationsReducer,
[apiSlice.reducerPath]: apiSlice.reducer
},
middleware: getDefaultMiddleware =>
getDefaultMiddleware().concat(apiSlice.middleware)
})

`reducer`パラメータで計算キーとして`apiSlice.reducerPath`フィールドを再利用して、キャッシュレデューサーが正しい場所に確実に追加されるようにすることができます。

`redux-thunk`のような既存の標準ミドルウェアはすべてストアのセットアップに保持する必要があり、APIスライスのミドルウェアは通常それらの後に配置されます。`configureStore`に`middleware`引数を指定し、提供されている`getDefaultMiddleware()`メソッドを呼び出し、返されたミドルウェア配列の最後に`apiSlice.middleware`を追加することで、これを実現できます。

クエリを使用した投稿の表示

コンポーネントでのクエリフックの使用

APIスライスを定義してストアに追加したので、生成された`useGetPostsQuery`フックを`<PostsList>`コンポーネントにインポートして使用できます。

現在、`<PostsList>`は`useSelector`、`useDispatch`、`useEffect`を具体的にインポートし、ストアから投稿データと読み込み状態を読み取り、マウント時に`fetchPosts()`サンクをディスパッチしてデータフェッチをトリガーしています。**`useGetPostsQueryHook`はこれらすべてを置き換えます!**

このフックを使用すると`<PostsList>`がどのように見えるかを見てみましょう。

features/posts/PostsList.js
import React from 'react'
import { Link } from 'react-router-dom'

import { Spinner } from '../../components/Spinner'
import { PostAuthor } from './PostAuthor'
import { TimeAgo } from './TimeAgo'
import { ReactionButtons } from './ReactionButtons'

import { useGetPostsQuery } from '../api/apiSlice'

let PostExcerpt = ({ post }) => {
return (
<article className="post-excerpt" key={post.id}>
<h3>{post.title}</h3>
<div>
<PostAuthor userId={post.user} />
<TimeAgo timestamp={post.date} />
</div>
<p className="post-content">{post.content.substring(0, 100)}</p>

<ReactionButtons post={post} />
<Link to={`/posts/${post.id}`} className="button muted-button">
View Post
</Link>
</article>
)
}

export const PostsList = () => {
const {
data: posts,
isLoading,
isSuccess,
isError,
error
} = useGetPostsQuery()

let content

if (isLoading) {
content = <Spinner text="Loading..." />
} else if (isSuccess) {
content = posts.map(post => <PostExcerpt key={post.id} post={post} />)
} else if (isError) {
content = <div>{error.toString()}</div>
}

return (
<section className="posts-list">
<h2>Posts</h2>
{content}
</section>
)
}

概念的には、`<PostsList>`は以前と同じ作業をすべて行っていますが、**複数の`useSelector`呼び出しと`useEffect`ディスパッチを`useGetPostsQuery()`への単一の呼び出しに置き換えることができました**。

ヒント

通常は、コンポーネントでキャッシュされたデータにアクセスするためにクエリフックを使用する必要があります。フェッチされたデータにアクセスするために独自の`useSelector`呼び出しを記述したり、フェッチをトリガーするために`useEffect`呼び出しを記述したり_すべきではありませ_ん!

生成された各クエリフックは、いくつかのフィールドを含む「結果」オブジェクトを返します。これには以下が含まれます。

  • `data`:サーバーからの実際のレスポンスコンテンツ。**レスポンスを受信するまで、このフィールドは`undefined`になります**。
  • `isLoading`:このフックが現在サーバーへの_最初_のリクエストを行っているかどうかを示すブール値。(パラメータが変更されて異なるデータをリクエストする場合、`isLoading`はfalseのままになります。)
  • `isFetching`:フックが現在サーバーへの_任意_のリクエストを行っているかどうかを示すブール値
  • `isSuccess`:フックがリクエストに成功し、キャッシュされたデータが利用可能である(つまり、`data`が定義されているはず)ことを示すブール値
  • `isError`:最後のリクエストでエラーが発生したことを示すブール値
  • `error`:シリアル化されたエラーオブジェクト

結果オブジェクトからフィールドを分割し、`data`を`posts`のように、より具体的な変数名に変更して、その内容を説明するのが一般的です。その後、ステータスブール値と`data/error`フィールドを使用して、必要なUIをレンダリングできます。ただし、TypeScriptを使用している場合は、元のオブジェクトをそのままにして、条件チェックでフラグを`result.isSuccess`のように参照する必要がある場合があります。これにより、TSは`data`が有効であると正しく推測できます。

以前は、ストアから投稿IDのリストを選択し、各`<PostExcerpt>`コンポーネントに投稿IDを渡し、ストアから個々の`Post`オブジェクトを個別に選択していました。`posts`配列には既にすべての投稿オブジェクトが含まれているため、投稿オブジェクト自体をプロップとして渡すように戻しました。

投稿のソート

残念ながら、投稿の表示順序が正しくありません。以前は、レデューサーレベルで`createEntityAdapter`のソートオプションを使用して日付でソートしていました。APIスライスはサーバーから返された正確な配列をキャッシュしているだけなので、特定のソートは行われていません。サーバーが返送した順序がそのまま使用されています。

これを処理するには、いくつかの異なるオプションがあります。今のところ、`<PostsList>`自体の中でソートを行い、後で他のオプションとそのトレードオフについて説明します。

posts.sort() を直接呼び出すことはできません。なぜなら Array.sort() は既存の配列を直接変更してしまうからです。そのため、最初にコピーを作成する必要があります。再レンダリングごとに再ソートを避けるために、useMemo() フックでソートを実行できます。また、postsundefined の場合に備えて、常にソート対象の配列が存在するように、デフォルトの空の配列を指定します。

features/posts/PostsList.js
// omit setup

export const PostsList = () => {
const {
data: posts = [],
isLoading,
isSuccess,
isError,
error
} = useGetPostsQuery()

const sortedPosts = useMemo(() => {
const sortedPosts = posts.slice()
// Sort posts in descending chronological order
sortedPosts.sort((a, b) => b.date.localeCompare(a.date))
return sortedPosts
}, [posts])

let content

if (isLoading) {
content = <Spinner text="Loading..." />
} else if (isSuccess) {
content = sortedPosts.map(post => <PostExcerpt key={post.id} post={post} />)
} else if (isError) {
content = <div>{error.toString()}</div>
}

return (
<section className="posts-list">
<h2>Posts</h2>
{content}
</section>
)
}

個々の投稿の表示

<PostsList> を更新して*すべて*の投稿のリストを取得し、リスト内に各 Post の一部を表示するようにしました。しかし、いずれかの投稿の「投稿を表示」をクリックすると、<SinglePostPage> コンポーネントは古い state.posts スライスから投稿を見つけることができず、「投稿が見つかりません!」というエラーを表示します。 <SinglePostPage> も RTK Query を使用するように更新する必要があります。

これを行うには、いくつかの方法があります。1つは、<SinglePostPage> で同じ useGetPostsQuery() フックを呼び出し、投稿の配列*全体*を取得し、表示する必要がある Post オブジェクトを1つだけ見つける方法です。クエリフックには、フック内で同じルックアップを事前に実行できる selectFromResult オプションもあります。これは後で実際に見ていきます。

代わりに、IDに基づいてサーバーから単一の投稿をリクエストできる別のエンドポイント定義を追加してみます。これはやや冗長ですが、RTK Query を使用して引数に基づいてクエリリクエストをカスタマイズする方法を確認できます。

単一投稿クエリエンドポイントの追加

apiSlice.js に、getPost(今回は「s」なし)という別のクエリエンドポイント定義を追加します。

features/api/apiSlice.js
export const apiSlice = createApi({
reducerPath: 'api',
baseQuery: fetchBaseQuery({ baseUrl: '/fakeApi' }),
endpoints: builder => ({
getPosts: builder.query({
query: () => '/posts'
}),
getPost: builder.query({
query: postId => `/posts/${postId}`
})
})
})

export const { useGetPostsQuery, useGetPostQuery } = apiSlice

getPost エンドポイントは既存の getPosts エンドポイントとよく似ていますが、query パラメーターが異なります。ここでは、querypostId という引数を取り、その postId を使用してサーバーURLを構築しています。これにより、特定の1つの Post オブジェクトに対するサーバーリクエストを作成できます。

これにより、新しい useGetPostQuery フックも生成されるため、それもエクスポートします。

クエリ引数とキャッシュキー

現在、<SinglePostPage> は ID に基づいて state.posts から1つの Post エントリを読み取っています。新しい useGetPostQuery フックを呼び出し、メインリストと同様の読み込み状態を使用するように更新する必要があります。

features/posts/SinglePostPage.js
import React from 'react'
import { Link } from 'react-router-dom'

import { Spinner } from '../../components/Spinner'
import { useGetPostQuery } from '../api/apiSlice'

import { PostAuthor } from './PostAuthor'
import { TimeAgo } from './TimeAgo'
import { ReactionButtons } from './ReactionButtons'

export const SinglePostPage = ({ match }) => {
const { postId } = match.params

const { data: post, isFetching, isSuccess } = useGetPostQuery(postId)

let content
if (isFetching) {
content = <Spinner text="Loading..." />
} else if (isSuccess) {
content = (
<article className="post">
<h2>{post.title}</h2>
<div>
<PostAuthor userId={post.user} />
<TimeAgo timestamp={post.date} />
</div>
<p className="post-content">{post.content}</p>
<ReactionButtons post={post} />
<Link to={`/editPost/${post.id}`} className="button">
Edit Post
</Link>
</article>
)
}

return <section>{content}</section>
}

ルーターマッチから読み取った postIduseGetPostQuery に引数として渡していることに注意してください。クエリフックはそれを使用してリクエストURLを構築し、この特定の Post オブジェクトを取得します。

では、このデータはどのようにキャッシュされているのでしょうか?投稿エントリの1つで「投稿を表示」をクリックし、この時点での Redux ストアの内部を見てみましょう。

RTK Query data cached in the store state

ストアの設定から予想されるように、トップレベルの state.api スライスがあります。その中には queries というセクションがあり、現在2つのアイテムがあります。キー getPosts(undefined) は、getPosts エンドポイントで行ったリクエストのメタデータとレスポンスの内容を表しています。同様に、キー getPost('abcd1234') は、この1つの投稿に対して行った特定のリクエスト用です。

RTK Query は、一意のエンドポイント + 引数の組み合わせごとに「キャッシュキー」を作成し、各キャッシュキーの結果を個別に保存します。つまり、**同じクエリフックを複数回使用し、異なるクエリパラメータを渡すことができ、各結果は Redux ストアに個別にキャッシュされます**。

ヒント

複数のコンポーネントで同じデータが必要な場合は、各コンポーネントで同じ引数を使用して同じクエリフックを呼び出すだけです!たとえば、3つの異なるコンポーネントで useGetPostQuery('123') を呼び出すことができ、RTK Query はデータが一度だけフェッチされるようにし、各コンポーネントは必要に応じて再レンダリングされます。

また、**クエリパラメータは*単一の*値でなければならないことに注意することが重要です!** 複数のパラメータを渡す必要がある場合は、複数のフィールドを含むオブジェクトを渡す必要があります(createAsyncThunk とまったく同じです)。RTK Query はフィールドの「浅い安定した」比較を実行し、いずれかのフィールドが変更された場合にデータを再フェッチします。

左側のリストのアクションの名前は、posts/fetchPosts/fulfilled ではなく api/executeQuery/fulfilled のように、より一般的で記述的でないことに注意してください。これは、追加の抽象化レイヤーを使用することのトレードオフです。個々のアクションには action.meta.arg.endpointName の下に特定のエンドポイント名が含まれていますが、アクション履歴リストでは簡単に見ることができません。

ヒント

Redux DevTools には、RTK Query データをより使いやすい形式で specifically 表示する「RTK Query」タブがあります。これには、各エンドポイントとキャッシュ結果に関する情報、クエリタイミングの統計などが含まれます。

ミューテーションによる投稿の作成

「クエリ」エンドポイントを定義することでサーバーからデータを取得する方法を見てきましたが、サーバーへの更新の送信はどうでしょうか?

RTK Query を使用すると、サーバー上のデータを更新する**ミューテーションエンドポイント**を定義できます。新しい投稿を追加できるミューテーションを追加してみましょう。

新しい投稿ミューテーションエンドポイントの追加

ミューテーションエンドポイントの追加は、クエリエンドポイントの追加と非常によく似ています。最大の 違いは、builder.query() の代わりに builder.mutation() を使用してエンドポイントを定義することです。また、HTTPメソッドを 'POST' リクエストに変更する必要があり、リクエストの本文も提供する必要があります。

features/api/apiSlice.js
export const apiSlice = createApi({
reducerPath: 'api',
baseQuery: fetchBaseQuery({ baseUrl: '/fakeApi' }),
endpoints: builder => ({
getPosts: builder.query({
query: () => '/posts'
}),
getPost: builder.query({
query: postId => `/posts/${postId}`
}),
addNewPost: builder.mutation({
query: initialPost => ({
url: '/posts',
method: 'POST',
// Include the entire post object as the body of the request
body: initialPost
})
})
})
})

export const {
useGetPostsQuery,
useGetPostQuery,
useAddNewPostMutation
} = apiSlice

ここでは、query オプションは {url, method, body} を含むオブジェクトを返します。リクエストを行うために fetchBaseQuery を使用しているため、body フィールドは自動的に JSON シリアライズされます。

クエリエンドポイントと同様に、API スライスはミューテーションエンドポイントの React フックを自動的に生成します。この場合は useAddNewPostMutation です。

コンポーネントでのミューテーションフックの使用

<AddPostForm> は、「投稿を保存」ボタンをクリックするたびに投稿を追加するための非同期サンクをすでにディスパッチしています。そのためには、useDispatchaddNewPost サンクをインポートする必要があります。ミューテーションフックはこれらの両方を置き換え、使用パターンは非常によく似ています。

features/posts/AddPostForm
import React, { useState } from 'react'
import { useSelector } from 'react-redux'

import { Spinner } from '../../components/Spinner'
import { useAddNewPostMutation } from '../api/apiSlice'
import { selectAllUsers } from '../users/usersSlice'

export const AddPostForm = () => {
const [title, setTitle] = useState('')
const [content, setContent] = useState('')
const [userId, setUserId] = useState('')

const [addNewPost, { isLoading }] = useAddNewPostMutation()
const users = useSelector(selectAllUsers)

const onTitleChanged = e => setTitle(e.target.value)
const onContentChanged = e => setContent(e.target.value)
const onAuthorChanged = e => setUserId(e.target.value)

const canSave = [title, content, userId].every(Boolean) && !isLoading

const onSavePostClicked = async () => {
if (canSave) {
try {
await addNewPost({ title, content, user: userId }).unwrap()
setTitle('')
setContent('')
setUserId('')
} catch (err) {
console.error('Failed to save the post: ', err)
}
}
}

// omit rendering logic
}

ミューテーションフックは、2つの値を持つ配列を返します。

  • 最初の値は「トリガー関数」です。呼び出されると、指定された引数でサーバーにリクエストを行います。これは事実上、すぐに自身をディスパッチするように既にラップされているサンクのようなものです。
  • 2番目の値は、現在進行中のリクエスト(存在する場合)に関するメタデータを含むオブジェクトです。これには、リクエストが進行中であることを示す isLoading フラグが含まれます。

既存のサンクディスパッチとコンポーネントの読み込み状態を、useAddNewPostMutation フックのトリガー関数と isLoading フラグに置き換えることができ、コンポーネントの残りの部分は同じままです。

サンクディスパッチと同様に、初期投稿オブジェクトを使用して addNewPost を呼び出します。これは、.unwrap() メソッドを持つ特別な Promise を返し、標準の try/catch ブロックで潜在的なエラーを処理するために await addNewPost().unwrap() を使用できます。

キャッシュされたデータの更新

「投稿を保存」をクリックすると、ブラウザの DevTools の [ネットワーク] タブで HTTP POST リクエストが成功したことを確認できます。ただし、そこに戻っても、新しい投稿は <PostsList> に表示されません。メモリにはまだ同じキャッシュデータがあります。

追加したばかりの新しい投稿が表示されるように、RTK Query にキャッシュされた投稿リストを更新するように指示する必要があります。

投稿の手動再取得

最初のオプションは、特定のエンドポイントのデータを強制的に再取得するように RTK Query に指示することです。クエリフックの結果オブジェクトには、再取得を強制するために呼び出すことができる refetch 関数が含まれています。「投稿の再取得」ボタンを一時的に <PostsList> に追加し、新しい投稿を追加した後にクリックできます。

また、 wcześniej クエリフックには、データの*最初*のリクエストの場合は true である isLoading フラグと、データの*任意*のリクエストが進行中の場合は true である isFetching フラグの両方があることがわかりました。 isFetching フラグを確認し、再取得の進行中に投稿のリスト全体をローディングスピナーに置き換えることができます。しかし、それは少し煩わしいかもしれません。それに、すでにこれらの投稿がすべてあるので、なぜそれらを完全に隠すべきなのでしょうか?

代わりに、データが古いことを示すために既存の投稿リストを部分的に透明にすることができますが、再取得中は表示したままにします。リクエストが完了するとすぐに、通常どおり投稿リストの表示に戻ることができます。

features/posts/PostsList.js
import React, { useMemo } from 'react'
import { Link } from 'react-router-dom'
import classnames from 'classnames'

// omit other imports and PostExcerpt

export const PostsList = () => {
const {
data: posts = [],
isLoading,
isFetching,
isSuccess,
isError,
error,
refetch
} = useGetPostsQuery()

const sortedPosts = useMemo(() => {
const sortedPosts = posts.slice()
sortedPosts.sort((a, b) => b.date.localeCompare(a.date))
return sortedPosts
}, [posts])

let content

if (isLoading) {
content = <Spinner text="Loading..." />
} else if (isSuccess) {
const renderedPosts = sortedPosts.map(post => (
<PostExcerpt key={post.id} post={post} />
))

const containerClassname = classnames('posts-container', {
disabled: isFetching
})

content = <div className={containerClassname}>{renderedPosts}</div>
} else if (isError) {
content = <div>{error.toString()}</div>
}

return (
<section className="posts-list">
<h2>Posts</h2>
<button onClick={refetch}>Refetch Posts</button>
{content}
</section>
)
}

新しい投稿を追加してから「投稿の再取得」をクリックすると、投稿リストが数秒間半透明になり、上部に新しい投稿が追加されて再レンダリングされるはずです。

キャッシュの無効化による自動更新

ユーザーが手動でクリックしてデータを再取得することは、場合によっては必要ですが、通常の使用には適していません。

「サーバー」には、追加したばかりの投稿を含む、すべての投稿の完全なリストがあることがわかっています。理想的には、ミューテーションリクエストが完了するとすぐに、アプリが更新された投稿リストを自動的に再取得するようにします。そうすれば、クライアント側のキャッシュされたデータがサーバーのデータと同期していることがわかります。

**RTK Query を使用すると、クエリとミューテーションの関係を定義して、「タグ」を使用してデータの自動再取得を有効にすることができます**。「タグ」は、特定の種類のデータに名前を付け、キャッシュの一部を*無効化*できる文字列または小さなオブジェクトです。キャッシュタグが無効化されると、RTK Query はそのタグでマークされたエンドポイントを自動的に再取得します。

基本的なタグの使用には、API スライスに3つの情報を追加する必要があります。

  • API スライスオブジェクトのルート tagTypes フィールド。'Post' などのデータ型の文字列タグ名の配列を宣言します。
  • クエリエンドポイントの providesTags 配列。そのクエリ内のデータを記述するタグのセットを一覧表示します。
  • ミューテーションエンドポイントの invalidatesTags 配列。そのミューテーションが実行されるたびに無効化されるタグのセットを一覧表示します。

API スライスに 'Post' という単一のタグを追加して、新しい投稿を追加するたびに getPosts エンドポイントを自動的に再取得できます。

features/api/apiSlice.js
export const apiSlice = createApi({
reducerPath: 'api',
baseQuery: fetchBaseQuery({ baseUrl: '/fakeApi' }),
tagTypes: ['Post'],
endpoints: builder => ({
getPosts: builder.query({
query: () => '/posts',
providesTags: ['Post']
}),
getPost: builder.query({
query: postId => `/posts/${postId}`
}),
addNewPost: builder.mutation({
query: initialPost => ({
url: '/posts',
method: 'POST',
body: initialPost
}),
invalidatesTags: ['Post']
})
})
})

これで完了です!これで、「投稿を保存」をクリックすると、<PostsList> コンポーネントが数秒後に自動的にグレー表示され、新しく追加された投稿が上部に再レンダリングされます。

ここでリテラル文字列 'Post' に特別な意味はないことに注意してください。'Fred''qwerty'、またはその他の任意の文字列を使用できます。重要なのは、各フィールドで同じ文字列を使用することです。これにより、RTK Query は「このミューテーションが発生したときに、同じタグ文字列がリストされているすべてのエンドポイントを無効化する」ことを認識できます。

学習内容

RTK Query を使用すると、データの取得、キャッシュ、読み込み状態の管理方法の実際の内容は抽象化されます。これにより、アプリケーションコードが大幅に簡素化され、意図したアプリの動作に関するより高度な懸念事項に集中できます。RTK Query は、既に見てきたのと同じ Redux Toolkit API を使用して実装されているため、Redux DevTools を使用して、時間の経過に伴う状態の変化を確認できます。

まとめ
  • RTK Query は、Redux Toolkit に含まれるデータ取得およびキャッシュソリューションです。
    • RTK Query は、キャッシュされたサーバーデータを管理するプロセスを抽象化し、読み込み状態のロジック、結果の保存、リクエストの作成を記述する必要性を排除します。
    • RTK Query は、非同期サンクなど、Redux で使用されているのと同じパターンに基づいて構築されています。
  • RTK Query は、アプリケーションごとに createApi を使用して定義された単一の「API スライス」を使用します。
    • RTK Query は、UI に依存しないバージョンと React 固有のバージョンの createApi を提供します。
    • API スライスは、異なるサーバー操作に対して複数の「エンドポイント」を定義します。
    • React 統合を使用する場合、API スライスには自動生成された React hooks が含まれます。
  • クエリエンドポイントは、サーバーからのデータの取得とキャッシュを許可します。
    • クエリ hooks は、data 値と読み込み状態フラグを返します。
    • クエリは、手動で再取得するか、キャッシュの無効化に「タグ」を使用して自動的に再取得できます。
  • ミューテーションエンドポイントは、サーバー上のデータの更新を許可します。
    • ミューテーション hooks は、更新リクエストを送信する「トリガー」関数と読み込み状態を返します。
    • トリガー関数は、「アンラップ」して待機できる Promise を返します。

次のステップ

RTK Query は、堅牢なデフォルトの動作を提供しますが、リクエストの管理方法やキャッシュされたデータの処理方法をカスタマイズするための多くのオプションも含まれています。パート 8: RTK Query の高度なパターンでは、これらのオプションを使用して、楽観的更新などの便利な機能を実装する方法について説明します。