RTK Queryへの移行
- Redux Toolkit +
createAsyncThunk
で実装された従来のデータ取得ロジックを、Redux Toolkit Query を使用するように変換する方法
概要
Redux アプリケーションにおける副作用の最も一般的なユースケースは、データの取得です。Redux アプリケーションは通常、thunk、saga、observable などのツールを使用して AJAX リクエストを行い、リクエストの結果に基づいてアクションをディスパッチします。そして、Reducer はこれらのアクションを listen して、読み込み状態を管理し、取得したデータをキャッシュします。
RTK Query は、データ取得のユースケースを解決するために特別に構築されています。thunk や他の副作用アプローチを使用するすべての状況を置き換えることはできませんが、**RTK Query を使用することで、手書きの副作用ロジックのほとんどが不要になるはずです**。
RTK Query は、ユーザーが以前に `createAsyncThunk` を使用していた可能性のある多くの重複する動作 (キャッシュ目的、リクエストライフサイクル管理 (例: `isUninitialized`、`isLoading`、`isError` 状態) など) をカバーすることが期待されています。
既存の Redux ツールから RTK Query にデータ取得機能を移行するには、適切なエンドポイントを RTK Query API スライスに追加し、以前の機能コードを削除する必要があります。ツールの動作が異なり、一方が他方を置き換えるため、通常、両方に共通のコードはあまり含まれません。
RTK Query をゼロから使い始める場合は、`RTK Query クイックスタート`も参照してください。
例 - Redux Toolkit から RTK Query へのデータ取得ロジックの移行
Redux でシンプルでキャッシュされたデータ取得ロジックを実装する一般的な方法は、`createSlice` を使用してスライスを設定し、クエリの関連する `data` と `status` を含む状態にし、`createAsyncThunk` を使用して非同期リクエストのライフサイクルを処理することです。以下では、そのような実装の例と、そのコードを RTK Query を使用するように移行する方法について説明します。
RTK Query は、以下に示す thunk の例で作成されるよりも多くの機能を提供します。この例は、特定の実装を RTK Query でどのように置き換えることができるかを示すことのみを目的としています。
設計仕様
この例のツールの設計仕様は次のとおりです。
- API: https://pokeapi.co/api/v2/pokemon/bulbasaur を使用して `pokemon` のデータを取得するためのフックを提供します。ここで、bulbasaur は任意のポケモン名にすることができます。
- 指定された名前のリクエストは、セッション中にまだ送信されていない場合にのみ送信する必要があります。
- フックは、指定されたポケモン名のリクエストの現在の状態 (`uninitialized`、`pending`、`fulfilled`、`rejected` のいずれか) を提供する必要があります。
- フックは、指定されたポケモン名の現在のデータを提供する必要があります。
上記の仕様を念頭に置いて、まず `createAsyncThunk` と `createSlice` を使用した従来の実装方法の概要を見てみましょう。
`createSlice` & `createAsyncThunk` を使用した実装
スライスファイル
以下の 3 つのコードスニペットは、スライスファイルを構成しています。このファイルは、非同期リクエストのライフサイクルの管理と、指定されたポケモン名のデータとリクエスト状態の格納を担当します。
Thunk アクションクリエーター
以下では、`createAsyncThunk` を使用して thunk アクションクリエーターを作成し、非同期リクエストのライフサイクルを管理しています。これは、コンポーネントとフック内でディスパッチ可能になり、ポケモンデータのリクエストを発生させることができます。`createAsyncThunk` 自体は、リクエストのライフサイクルメソッド (`pending`、`fulfilled`、`rejected`) のディスパッチを処理します。これらのメソッドはスライス内で処理します。
import { createAsyncThunk, createSlice } from '@reduxjs/toolkit'
import type { Pokemon } from './types'
import type { RootState } from '../store'
export const fetchPokemonByName = createAsyncThunk<Pokemon, string>(
'pokemon/fetchByName',
async (name, { rejectWithValue }) => {
const response = await fetch(`https://pokeapi.co/api/v2/pokemon/${name}`)
const data = await response.json()
if (response.status < 200 || response.status >= 300) {
return rejectWithValue(data)
}
return data
},
)
// slice & selectors omitted
スライス
以下は、`createSlice` で作成した `slice` です。リクエスト処理ロジックを含む reducer がここで定義されており、検索に使用した名前に基づいて適切な「状態」と「データ」を状態に格納します。
// imports & thunk action creator omitted
type RequestState = 'pending' | 'fulfilled' | 'rejected'
export const pokemonSlice = createSlice({
name: 'pokemon',
initialState: {
dataByName: {} as Record<string, Pokemon | undefined>,
statusByName: {} as Record<string, RequestState | undefined>,
},
reducers: {},
extraReducers: (builder) => {
// When our request is pending:
// - store the 'pending' state as the status for the corresponding pokemon name
builder.addCase(fetchPokemonByName.pending, (state, action) => {
state.statusByName[action.meta.arg] = 'pending'
})
// When our request is fulfilled:
// - store the 'fulfilled' state as the status for the corresponding pokemon name
// - and store the received payload as the data for the corresponding pokemon name
builder.addCase(fetchPokemonByName.fulfilled, (state, action) => {
state.statusByName[action.meta.arg] = 'fulfilled'
state.dataByName[action.meta.arg] = action.payload
})
// When our request is rejected:
// - store the 'rejected' state as the status for the corresponding pokemon name
builder.addCase(fetchPokemonByName.rejected, (state, action) => {
state.statusByName[action.meta.arg] = 'rejected'
})
},
})
// selectors omitted
セレクター
以下は定義済みのセレクターです。これにより、後で指定されたポケモン名の適切な状態とデータにアクセスできます。
// imports, thunk action creator & slice omitted
export const selectStatusByName = (state: RootState, name: string) =>
state.pokemon.statusByName[name]
export const selectDataByName = (state: RootState, name: string) =>
state.pokemon.dataByName[name]
ストア
アプリケーションの `store` では、状態ツリーの `pokemon` ブランチ下にスライスから対応する reducer を含めます。これにより、ストアはアプリケーションの実行時にディスパッチされるリクエストの適切なアクションを、以前に定義したロジックを使用して処理できます。
- TypeScript
- JavaScript
import { configureStore } from '@reduxjs/toolkit'
import { pokemonSlice } from './services/pokemonSlice'
export const store = configureStore({
reducer: {
pokemon: pokemonSlice.reducer,
},
})
export type RootState = ReturnType<typeof store.getState>
import { configureStore } from '@reduxjs/toolkit'
import { pokemonSlice } from './services/pokemonSlice'
export const store = configureStore({
reducer: {
pokemon: pokemonSlice.reducer,
},
})
アプリケーション内でストアにアクセスできるようにするには、`App` コンポーネントを `react-redux` の `Provider` コンポーネントでラップします。
import { render } from 'react-dom'
import { Provider } from 'react-redux'
import App from './App'
import { store } from './store'
const rootElement = document.getElementById('root')
render(
<Provider store={store}>
<App />
</Provider>,
rootElement,
)
カスタムフック
以下では、適切なタイミングでリクエストの送信を管理し、ストアから適切なデータと状態を取得するためのフックを作成しています。Redux ストアと通信するために、`react-redux` から `useDispatch` と `useSelector` を使用しています。フックの最後に、コンポーネントでアクセスできるように、情報を整理されたパッケージ化されたオブジェクトとして返します。
- TypeScript
- JavaScript
import { useEffect } from 'react'
import { useSelector } from 'react-redux'
import { useAppDispatch } from './store'
import type { RootState } from './store'
import {
fetchPokemonByName,
selectStatusByName,
selectDataByName,
} from './services/pokemonSlice'
export function useGetPokemonByNameQuery(name: string) {
const dispatch = useAppDispatch()
// select the current status from the store state for the provided name
const status = useSelector((state: RootState) =>
selectStatusByName(state, name)
)
// select the current data from the store state for the provided name
const data = useSelector((state: RootState) => selectDataByName(state, name))
useEffect(() => {
// upon mount or name change, if status is uninitialized, send a request
// for the pokemon name
if (status === undefined) {
dispatch(fetchPokemonByName(name))
}
}, [status, name, dispatch])
// derive status booleans for ease of use
const isUninitialized = status === undefined
const isLoading = status === 'pending' || status === undefined
const isError = status === 'rejected'
const isSuccess = status === 'fulfilled'
// return the import data for the caller of the hook to use
return { data, isUninitialized, isLoading, isError, isSuccess }
}
import { useEffect } from 'react'
import { useSelector } from 'react-redux'
import { useAppDispatch } from './store'
import {
fetchPokemonByName,
selectStatusByName,
selectDataByName,
} from './services/pokemonSlice'
export function useGetPokemonByNameQuery(name) {
const dispatch = useAppDispatch()
// select the current status from the store state for the provided name
const status = useSelector((state) => selectStatusByName(state, name))
// select the current data from the store state for the provided name
const data = useSelector((state) => selectDataByName(state, name))
useEffect(() => {
// upon mount or name change, if status is uninitialized, send a request
// for the pokemon name
if (status === undefined) {
dispatch(fetchPokemonByName(name))
}
}, [status, name, dispatch])
// derive status booleans for ease of use
const isUninitialized = status === undefined
const isLoading = status === 'pending' || status === undefined
const isError = status === 'rejected'
const isSuccess = status === 'fulfilled'
// return the import data for the caller of the hook to use
return { data, isUninitialized, isLoading, isError, isSuccess }
}
カスタムフックの使用
上記のコードはすべての設計仕様を満たしているので、使ってみましょう。以下は、コンポーネントでフックを呼び出し、関連するデータと状態のブール値を返す方法を示しています。
以下の実装は、コンポーネントで次の動作を提供します。
- コンポーネントがマウントされたときに、指定されたポケモン名のリクエストがセッション中にまだ送信されていない場合は、リクエストを送信します。
- フックは、常に最新の受信 `data` を提供し、リクエスト状態のブール値 `isUninitialized`、`isPending`、`isFulfilled`、`isRejected` も提供します。これにより、任意の時点での現在の UI を状態の関数として決定できます。
import * as React from 'react'
import { useGetPokemonByNameQuery } from './hooks'
export default function App() {
const { data, isError, isLoading } = useGetPokemonByNameQuery('bulbasaur')
return (
<div className="App">
{isError ? (
<>Oh no, there was an error</>
) : isLoading ? (
<>Loading...</>
) : data ? (
<>
<h3>{data.species.name}</h3>
<img src={data.sprites.front_shiny} alt={data.species.name} />
</>
) : null}
</div>
)
}
上記のコードの実行可能な例を以下に示します。
RTK Queryへの変換
上記の実装は指定された要件に対しては完全に機能しますが、さらにエンドポイントを追加するためにコードを拡張すると、多くの繰り返しが必要になる可能性があります。また、すぐには明らかではない特定の制限事項もあります。たとえば、複数のコンポーネントが同時にレンダリングされてフックを呼び出すと、それぞれが同時にフシギダネのリクエストを送信してしまいます!
以下では、上記のコードをRTK Queryを使用するように移行することで、多くの定型コードを回避する方法を説明します。RTK Queryは、より細かいレベルでリクエストの重複排除を処理し、上記のような不要な重複リクエストの送信を防ぐなど、他の多くの状況も処理します。
APIスライスファイル
以下のコードは、APIスライスの定義です。これはネットワークAPIインターフェース層として機能し、createApi
を使用して作成されます。このファイルにはエンドポイント定義が含まれ、createApi
は、必要な場合にのみリクエストを発行する自動生成されたフックと、リクエストステータスのライフサイクルを表すブール値を提供します。
これは、サンク、スライス定義、セレクター、そしてカスタムフックを含む、スライスファイル全体の上記で実装されたロジックを完全に網羅します!
- TypeScript
- JavaScript
import { createApi, fetchBaseQuery } from '@reduxjs/toolkit/query/react'
import type { Pokemon } from './types'
export const api = createApi({
baseQuery: fetchBaseQuery({ baseUrl: 'https://pokeapi.co/api/v2/' }),
reducerPath: 'pokemonApi',
endpoints: (build) => ({
getPokemonByName: build.query<Pokemon, string>({
query: (name) => `pokemon/${name}`,
}),
}),
})
export const { useGetPokemonByNameQuery } = api
import { createApi, fetchBaseQuery } from '@reduxjs/toolkit/query/react'
export const api = createApi({
baseQuery: fetchBaseQuery({ baseUrl: 'https://pokeapi.co/api/v2/' }),
reducerPath: 'pokemonApi',
endpoints: (build) => ({
getPokemonByName: build.query({
query: (name) => `pokemon/${name}`,
}),
}),
})
export const { useGetPokemonByNameQuery } = api
APIスライスをストアに接続する
API定義を作成したので、ストアに接続する必要があります。そのためには、作成したapi
からreducerPath
とmiddleware
プロパティを使用する必要があります。これにより、ストアは生成されたフックが使用する内部アクションを処理し、生成されたAPIロジックが状態を正しく見つけ、キャッシング、無効化、サブスクリプション、ポーリングなどを管理するためのロジックを追加できます。
- TypeScript
- JavaScript
import { configureStore } from '@reduxjs/toolkit'
import { pokemonSlice } from './services/pokemonSlice'
import { api } from './services/api'
export const store = configureStore({
reducer: {
pokemon: pokemonSlice.reducer,
[api.reducerPath]: api.reducer,
},
middleware: (gDM) => gDM().concat(api.middleware),
})
export type RootState = ReturnType<typeof store.getState>
import { configureStore } from '@reduxjs/toolkit'
import { pokemonSlice } from './services/pokemonSlice'
import { api } from './services/api'
export const store = configureStore({
reducer: {
pokemon: pokemonSlice.reducer,
[api.reducerPath]: api.reducer,
},
middleware: (gDM) => gDM().concat(api.middleware),
})
自動生成されたフックを使用する
この基本レベルでは、自動生成されたフックの使い方はカスタムフックと同じです!インポートパスを変更するだけで準備完了です!
import * as React from 'react'
- import { useGetPokemonByNameQuery } from './hooks'
+ import { useGetPokemonByNameQuery } from './services/api'
export default function App() {
const { data, isError, isLoading } = useGetPokemonByNameQuery('bulbasaur')
return (
<div className="App">
{isError ? (
<>Oh no, there was an error</>
) : isLoading ? (
<>Loading...</>
) : data ? (
<>
<h3>{data.species.name}</h3>
<img src={data.sprites.front_shiny} alt={data.species.name} />
</>
) : null}
</div>
)
}
未使用のコードをクリーンアップする
前述のように、api
定義は、createAsyncThunk
、createSlice
、およびカスタムフック定義を使用して以前に実装したすべてのロジックを置き換えています。
そのスライスをもう使用していないため、ストアからインポートとレデューサーを削除できます
import { configureStore } from '@reduxjs/toolkit'
- import { pokemonSlice } from './services/pokemonSlice'
import { api } from './services/api'
export const store = configureStore({
reducer: {
- pokemon: pokemonSlice.reducer,
[api.reducerPath]: api.reducer,
},
middleware: (gDM) => gDM().concat(api.middleware),
})
export type RootState = ReturnType<typeof store.getState>
スライスファイルとフックファイル全体を完全に削除することもできます!
- src/services/pokemonSlice.ts (-51 lines)
- src/hooks.ts (-34 lines)
これで、20行未満のコードで、設計仕様全体(およびそれ以上!)を再実装し、API定義にエンドポイントを追加することで簡単に拡張できるようになりました。
RTK Queryを使用したリファクタリングされた実装の実行可能な例を以下に示します