Thunkを使ったロジックの記述
このページは PageTurner AI で翻訳されました(ベータ版)。プロジェクト公式の承認はありません。 エラーを見つけましたか? 問題を報告 →
- 「thunk」とは何か、Reduxロジック記述に使用される理由
- thunkミドルウェアの動作原理
- thunk内で同期/非同期ロジックを記述するテクニック
- 一般的なthunkの使用パターン
Thunkの概要
Thunkとは?
「thunk」とは、"遅延作業を行うコード片"を意味するプログラミング用語です。ロジックを即時実行する代わりに、後で作業を実行するために使用できる関数本体やコードを記述します。
Reduxにおいて特に、「thunk」とはReduxストアのdispatchおよびgetStateメソッドと相互作用できるロジックを含む関数を記述するパターンです。
thunkを使用するには、redux-thunkミドルウェアをReduxストアの設定の一部として追加する必要があります。
thunkはReduxアプリで非同期ロジックを記述する標準的なアプローチであり、データ取得によく使用されます。ただし様々なタスクに使用可能で、同期ロジックと非同期ロジックの両方を含むことができます。
Thunkの記述方法
_thunk関数_とは、ReduxストアのdispatchメソッドとgetStateメソッドの2つの引数を受け取る関数です。thunk関数はアプリケーションコードから直接呼び出されません。代わりにstore.dispatch()に渡されます:
const thunkFunction = (dispatch, getState) => {
// logic here that can dispatch actions or read state
}
store.dispatch(thunkFunction)
thunk関数は任意の同期/非同期ロジックを含むことができ、いつでもdispatchやgetStateを呼び出せます。
Reduxコードが通常、手動でアクションオブジェクトを記述する代わりにdispatch用のアクションオブジェクトを生成するアクションクリエイターを使用するのと同様に、dispatchされるthunk関数を生成するために_thunkアクションクリエイター_を使用します。thunkアクションクリエイターは引数を受け取り、新しいthunk関数を返す関数です。thunkは通常、アクションクリエイターに渡された引数をクロージャで保持するため、ロジック内で使用できます:
// fetchTodoById is the "thunk action creator"
export function fetchTodoById(todoId) {
// fetchTodoByIdThunk is the "thunk function"
return async function fetchTodoByIdThunk(dispatch, getState) {
const response = await client.get(`/fakeApi/todo/${todoId}`)
dispatch(todosLoaded(response.todos))
}
}
thunk関数とアクションクリエイターはfunctionキーワードまたはアロー関数のどちらでも記述可能で、意味的な違いはありません。同じfetchTodoById thunkはアロー関数を使って次のように記述できます:
export const fetchTodoById = todoId => async dispatch => {
const response = await client.get(`/fakeApi/todo/${todoId}`)
dispatch(todosLoaded(response.todos))
}
どちらの場合でも、thunkは他のReduxアクションと同様の方法でアクションクリエイターを呼び出してdispatchされます:
function TodoComponent({ todoId }) {
const dispatch = useDispatch()
const onFetchClicked = () => {
// Calls the thunk action creator, and passes the thunk function to dispatch
dispatch(fetchTodoById(todoId))
}
}
Thunkを使用する理由
thunkを使用すると、UI層から分離したRedux関連の追加ロジックを記述できます。このロジックには非同期リクエストやランダム値生成などの副作用や、複数アクションのdispatchやReduxストア状態へのアクセスが必要なロジックを含められます。
Reduxのreducerは副作用を含んではいけませんが、実際のアプリケーションには副作用のあるロジックが必要です。その一部はコンポーネント内に存在できますが、一部はUI層の外に存在する必要があります。thunk(および他のReduxミドルウェア)はそれらの副作用を配置する場所を提供します。
クリックハンドラーやuseEffectフック内で非同期リクエストを行い結果を処理するなど、ロジックをコンポーネント内に直接記述するのは一般的です。ただし多くの場合、そのロジックの可能な限り多くをUI層の外に移動する必要があります。これはロジックのテスト容易性向上、UI層を可能な限り薄く「プレゼンテーショナル」に保つため、またはコードの再利用性と共有を改善するために行われます。
ある意味で、thunkは事前にどのReduxストアが使われるかを知らなくても、Reduxストアとやり取りする必要がある任意のコードを書ける抜け穴です。これによりロジックが特定のReduxストアインスタンスに縛られず、再利用可能な状態を保てます。
Detailed Explanation: Thunks, Connect, and "Container Components"
Historically, another reason to use thunks was to help keep React components "unaware of Redux". The connect API allowed passing action creators and "binding" them to automatically dispatch actions when called. Since components typically did not have access to dispatch internally, passing thunks to connect made it possible for components to just call this.props.doSomething(), without needing to know if it was a callback from a parent, dispatching a plain Redux action, dispatching a thunk performing sync or async logic, or a mock function in a test.
With the arrival of the React-Redux hooks API, that situation has changed. The community has switched away from the "container/presentational" pattern in general, and components now have access to dispatch directly via the useDispatch hook. This does mean that it's possible to have more logic directly inside of a component, such as an async fetch + dispatch of the results. However, thunks have access to getState, which components do not, and there's still value in moving that logic outside of components.
Thunkのユースケース
thunkは任意のロジックを含む汎用ツールであるため、多様な目的で使用できます。最も一般的なユースケースは以下の通りです:
-
コンポーネントからの複雑なロジックの分離
-
非同期リクエストやその他の非同期ロジックの実行
-
連続的または時間をかけて複数のアクションをディスパッチするロジック
-
意思決定のために
getStateへアクセスする必要があるロジック、またはアクションに他の状態値を含めるロジック
thunkはライフサイクルのない「ワンショット」関数であり、他のディスパッチされたアクションを認識できません。そのため、WebSocketのような永続的な接続の初期化には通常使用すべきではなく、他のアクションへの応答にも利用できません。
thunkは複雑な同期ロジックや、標準的なAJAXリクエストの実行とその結果に基づいたアクションのディスパッチなど、単純から中程度の非同期ロジックに最適です。
Redux Thunkミドルウェア
thunk関数をディスパッチするには、redux-thunkミドルウェアをReduxストアの設定の一部として追加する必要があります。
ミドルウェアの追加
Redux ToolkitのconfigureStore APIはストア作成時に自動的にthunkミドルウェアを追加するため、通常は追加設定なしで利用可能です。
手動でthunkミドルウェアをストアに追加する必要がある場合は、セットアッププロセスの一部としてapplyMiddleware()にthunkミドルウェアを渡すことで実現できます。
ミドルウェアの動作原理
まず、Reduxミドルウェアの一般的な動作を確認しましょう。
Reduxミドルウェアはすべて3つのネストされた関数シリーズとして記述されます:
-
外側の関数は
{dispatch, getState}を含む「ストアAPI」オブジェクトを受け取る -
中間の関数はチェーン内の次のミドルウェア(
next)または実際のstore.dispatchメソッドを受け取る -
内側の関数はミドルウェアチェーンを通過する各
actionで呼び出される
重要な点は、ミドルウェアがリデューサーに到達しないように値をインターセプトする限り、アクションオブジェクト以外の値をstore.dispatch()に渡せることです。
この前提を踏まえて、thunkミドルウェアの詳細を見ていきましょう。
thunkミドルウェアの実際の実装は非常に短く、約10行のみです。以下はソースコードにコメントを追加したものです:
// standard middleware definition, with 3 nested functions:
// 1) Accepts `{dispatch, getState}`
// 2) Accepts `next`
// 3) Accepts `action`
const thunkMiddleware =
({ dispatch, getState }) =>
next =>
action => {
// If the "action" is actually a function instead...
if (typeof action === 'function') {
// then call the function and pass `dispatch` and `getState` as arguments
return action(dispatch, getState)
}
// Otherwise, it's a normal action - send it onwards
return next(action)
}
言い換えると:
-
関数を
dispatchに渡すと、thunkミドルウェアはそれをアクションオブジェクトではなく関数として認識し、インターセプトして(dispatch, getState)を引数に関数を呼び出します -
通常のアクションオブジェクト(またはその他の値)の場合、チェーン内の次のミドルウェアへ転送されます
Thunkへの設定値の注入
thunkミドルウェアには1つのカスタマイズオプションがあります。セットアップ時にthunkミドルウェアのカスタムインスタンスを作成し、「追加引数」をミドルウェアに注入できます。ミドルウェアはこの追加値をすべてのthunk関数の第3引数として注入します。これは一般的に、thunk関数がAPIメソッドへのハードコードされた依存関係を持たないように、APIサービス層を注入するために使用されます:
import { withExtraArgument } from 'redux-thunk'
const serviceApi = createServiceApi('/some/url')
const thunkMiddlewareWithArg = withExtraArgument({ serviceApi })
Redux ToolkitのconfigureStoreは、getDefaultMiddlewareによるミドルウェアのカスタマイズの一部としてこれをサポートしています:
const store = configureStore({
reducer: rootReducer,
middleware: getDefaultMiddleware =>
getDefaultMiddleware({
thunk: {
extraArgument: { serviceApi }
}
})
})
追加引数として渡せる値は1つだけです。複数の値を渡す必要がある場合は、それらを含むオブジェクトを渡してください。
サンク関数はその追加値を第3引数として受け取ります:
export const fetchTodoById =
todoId => async (dispatch, getState, extraArgument) => {
// In this example, the extra arg is an object with an API service inside
const { serviceApi } = extraArgument
const response = await serviceApi.getTodo(todoId)
dispatch(todosLoaded(response.todos))
}
サンクの使用パターン
アクションのディスパッチ
サンクはdispatchメソッドにアクセスできます。これはアクションや他のサンクをディスパッチするために使用でき、複数のアクションを連続してディスパッチする場合(ただしこのパターンは最小限に抑えるべきです)や、プロセス内の複数ポイントでディスパッチが必要な複雑なロジックを調整するのに便利です。
// An example of a thunk dispatching other action creators,
// which may or may not be thunks themselves. No async code, just
// orchestration of higher-level synchronous logic.
function complexSynchronousThunk(someValue) {
return (dispatch, getState) => {
dispatch(someBasicActionCreator(someValue))
dispatch(someThunkActionCreator())
}
}
ステートへのアクセス
コンポーネントとは異なり、サンクはgetStateにもアクセスできます。これは現在のルートReduxステート値を取得するためにいつでも呼び出せ、現在のステートに基づいた条件付きロジックの実行に便利です。サンク内でステートを読み取る際はネストされたステートフィールドに直接アクセスするよりもセレクター関数を使うのが一般的ですが、どちらのアプローチでも問題ありません。
const MAX_TODOS = 5
function addTodosIfAllowed(todoText) {
return (dispatch, getState) => {
const state = getState()
// Could also check `state.todos.length < MAX_TODOS`
if (selectCanAddNewTodo(state, MAX_TODOS)) {
dispatch(todoAdded(todoText))
}
}
}
できるだけ多くのロジックをリデューサーに置くことが推奨されますが、サンク内に追加ロジックがあっても構いません。
リデューサーがアクションを処理するとステートは即座に更新されるため、ディスパッチ後にgetStateを呼び出せば更新後のステートを取得できます。
function checkStateAfterDispatch() {
return (dispatch, getState) => {
const firstState = getState()
dispatch(firstAction())
const secondState = getState()
if (secondState.someField != firstState.someField) {
dispatch(secondAction())
}
}
}
サンク内でステートにアクセスするもう一つの理由は、アクションに追加情報を埋めるためです。スライスリデューサーが自身のスライス外の値を読み取る必要がある場合、回避策としてサンクをディスパッチし、ステートから必要な値を抽出してから追加情報を含むプレーンなアクションをディスパッチすることがあります。
// One solution to the "cross-slice state in reducers" problem:
// read the current state in a thunk, and include all the necessary
// data in the action
function crossSliceActionThunk() {
return (dispatch, getState) => {
const state = getState()
// Read both slices out of state
const { a, b } = state
// Include data from both slices in the action
dispatch(actionThatNeedsMoreData(a, b))
}
}
非同期ロジックと副作用
サンクには非同期ロジックやlocalStorageの更新などの副作用を含めることができます。someResponsePromise.then()のようなPromiseチェーンを使用できますが、可読性のためには通常async/await構文が推奨されます。
非同期リクエストを行う際は、ローディング状態を追跡するために、リクエスト前後にアクションをディスパッチするのが標準的です。通常、リクエスト前に「pending」アクションをディスパッチしてローディング状態列挙型を「進行中」にマークします。リクエストが成功した場合は結果データと共に「fulfilled」アクションを、失敗した場合はエラー情報と共に「rejected」アクションをディスパッチします。
ここでのエラー処理は多くの人が考えるより複雑です。resPromise.then(dispatchFulfilled).catch(dispatchRejected)のようにチェーンすると、「fulfilled」アクションの処理中にネットワーク以外のエラーが発生した場合に「rejected」アクションがディスパッチされる可能性があります。リクエスト自体に関連するエラーのみを処理するためには、.then()の第二引数を使用する方が安全です:
function fetchData(someValue) {
return (dispatch, getState) => {
dispatch(requestStarted())
myAjaxLib.post('/someEndpoint', { data: someValue }).then(
response => dispatch(requestSucceeded(response.data)),
error => dispatch(requestFailed(error.message))
)
}
}
async/awaitを使用する場合、try/catchロジックの構成方法によりこれはさらに複雑になります。catchブロックがネットワークレベルのエラーのみを処理することを保証するには、エラー時に早期リターンし、「fulfilled」アクションを最後にディスパッチするようにロジックを再構成する必要があるかもしれません:
function fetchData(someValue) {
return async (dispatch, getState) => {
dispatch(requestStarted())
// Have to declare the response variable outside the try block
let response
try {
response = await myAjaxLib.post('/someEndpoint', { data: someValue })
} catch (error) {
// Ensure we only catch network errors
dispatch(requestFailed(error.message))
// Bail out early on failure
return
}
// We now have the result and there's no error. Dispatch "fulfilled".
dispatch(requestSucceeded(response.data))
}
}
この問題はReduxやサンクに限定されたものではなく、Reactコンポーネントのステートのみを扱う場合や、成功結果の追加処理を必要とする他のロジックでも発生し得ることに注意してください。
このパターンは確かに記述も読み取りも煩雑です。ほとんどの場合、リクエストとdispatch(requestSucceeded())を連続して行う典型的なtry/catchパターンでも問題ないでしょう。ただしこれが問題になり得ることは認識しておく価値があります。
サンクからの値の返却
デフォルトでは、store.dispatch(action)は実際のアクションオブジェクトを返します。ミドルウェアはdispatchから返される値をオーバーライドし、任意の別の値を返すことができます。例えば、ミドルウェアが常に42を返すように設定することも可能です:
const return42Middleware = storeAPI => next => action => {
const originalReturnValue = next(action)
return 42
}
// later
const result = dispatch(anyAction())
console.log(result) // 42
thunkミドルウェアはこの動作を実現しており、呼び出されたthunk関数が返す値をそのまま返します。
最も一般的な使用例は、thunkからPromiseを返すことです。これにより、thunkをディスパッチしたコードがPromiseの解決を待機し、thunkの非同期処理が完了したことを認識できます。これはコンポーネントが追加処理を調整する際によく使用されます:
const onAddTodoClicked = async () => {
await dispatch(saveTodo(todoText))
setTodoText('')
}
この機能を活用した巧妙なテクニックもあります:dispatchにしかアクセスできない状況で、Reduxストアの状態から一度だけ選択する方法としてthunkを流用できます。thunkをディスパッチするとその戻り値が得られるため、セレクターを受け取り状態を即座に呼び出して結果を返すthunkを記述できます。これはdispatchにはアクセスできるがgetStateにはアクセスできないReactコンポーネント内で有用です:
// In your Redux slices:
const getSelectedData = selector => (dispatch, getState) => {
return selector(getState())
}
// in a component
const onClick = () => {
const todos = dispatch(getSelectedData(selectTodos))
// do more logic with this data
}
これは推奨されるプラクティスというわけではありませんが、文法的に問題なく正常に動作します。
createAsyncThunkの使用
thunkを使用した非同期ロジックの記述はやや煩雑です。各thunkでは通常、「pending/fulfilled/rejected」に対応する3つの異なるアクションタイプとそれにマッチするアクションクリエイター、実際のthunkアクションクリエイターとthunk関数を定義する必要があります。エラー処理に関するエッジケースも扱わなければなりません。
Redux ToolkitにはcreateAsyncThunk APIが用意されており、これらのアクションの生成プロセス、Promiseライフサイクルに基づいたディスパッチ、正しいエラー処理を抽象化します。このAPIは部分的なアクションタイプ文字列(pending、fulfilled、rejectedのアクションタイプ生成に使用)と、実際の非同期リクエストを実行してPromiseを返す「ペイロード作成コールバック」を受け入れます。その後、リクエストの前後で適切な引数を持つアクションを自動的にディスパッチします。
これは非同期リクエストという特定のユースケースのための抽象化であるため、createAsyncThunkはthunkのすべてのユースケースに対応しているわけではありません。同期的なロジックやその他のカスタム動作が必要な場合は、代わりに通常のthunkを手動で記述する必要があります。
thunkアクションクリエイターには、pending、fulfilled、rejected用のアクションクリエイターがアタッチされています。createSliceのextraReducersオプションを使用してこれらのアクションタイプを監視し、スライスの状態を更新できます。
import { createSlice, createAsyncThunk } from '@reduxjs/toolkit'
// omit imports and state
export const fetchTodos = createAsyncThunk('todos/fetchTodos', async () => {
const response = await client.get('/fakeApi/todos')
return response.todos
})
const todosSlice = createSlice({
name: 'todos',
initialState,
reducers: {
// omit reducer cases
},
extraReducers: builder => {
builder
.addCase(fetchTodos.pending, (state, action) => {
state.status = 'loading'
})
.addCase(fetchTodos.fulfilled, (state, action) => {
const newEntities = {}
action.payload.forEach(todo => {
newEntities[todo.id] = todo
})
state.entities = newEntities
state.status = 'idle'
})
}
})
RTK Queryによるデータ取得
Redux Toolkitには新しいRTK Queryデータ取得APIが導入されています。RTK QueryはReduxアプリ向けに設計されたデータ取得およびキャッシュソリューションであり、データ取得管理のためのthunkやリデューサーを一切記述する必要をなくせます。
RTK Queryは内部ですべてのリクエストにcreateAsyncThunkを使用しており、キャッシュデータのライフタイムを管理するカスタムミドルウェアを備えています。
最初に、アプリが通信するサーバーエンドポイントの定義を含む「APIスライス」を作成します。各エンドポイントは、エンドポイント名とリクエストタイプに基づいて名前が付けられたReactフック(例:useGetPokemonByNameQuery)を自動生成します:
import { createApi, fetchBaseQuery } from '@reduxjs/toolkit/query/react'
export const pokemonApi = createApi({
reducerPath: 'pokemonApi',
baseQuery: fetchBaseQuery({ baseUrl: 'https://pokeapi.co/api/v2/' }),
endpoints: builder => ({
getPokemonByName: builder.query({
query: (name: string) => `pokemon/${name}`
})
})
})
export const { useGetPokemonByNameQuery } = pokemonApi
次に、生成されたAPIスライスリデューサーとカスタムミドルウェアをストアに追加します:
import { configureStore } from '@reduxjs/toolkit'
// Or from '@reduxjs/toolkit/query/react'
import { setupListeners } from '@reduxjs/toolkit/query'
import { pokemonApi } from './services/pokemon'
export const store = configureStore({
reducer: {
// Add the generated reducer as a specific top-level slice
[pokemonApi.reducerPath]: pokemonApi.reducer
},
// Adding the api middleware enables caching, invalidation, polling,
// and other useful features of `rtk-query`.
middleware: getDefaultMiddleware =>
getDefaultMiddleware().concat(pokemonApi.middleware)
})
最後に、自動生成されたReactフックをコンポーネントにインポートして呼び出します。このフックはコンポーネントのマウント時に自動的にデータを取得し、複数のコンポーネントが同じ引数で同じフックを使用する場合、キャッシュされた結果を共有します:
import { useGetPokemonByNameQuery } from './services/pokemon'
export default function Pokemon() {
// Using a query hook automatically fetches data and returns query values
const { data, error, isLoading } = useGetPokemonByNameQuery('bulbasaur')
// rendering logic
}
RTK Queryを試用し、ご自身のアプリケーションにおけるデータ取得コードの簡素化に役立つかどうか確認されることをお勧めします。
参考情報
-
ミドルウェアと副作用の背景:
-
Thunk チュートリアル: