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

Thunk を使ったロジックの記述

学習内容
  • "thunk" とは何で、なぜ Redux ロジックの記述に使用されるのか
  • thunk ミドルウェアの仕組み
  • thunk での同期および非同期ロジックの記述手法
  • 一般的な thunk の使用パターン

Thunk の概要

"thunk" とは何ですか?

"thunk" という単語はプログラミング用語で、「遅延した処理を行うコードの一部」を意味します。あるロジックを実行するのではなく、後で処理を実行するために使用できる関数本体またはコードを記述できます。

特に Redux の場合、「thunk」とは、Redux ストアの dispatch および getState メソッドと対話できるロジックを内部に持つ関数を記述するパターンです。

thunk を使用するには、Redux ストアの設定の一部として、redux-thunk ミドルウェアを追加する必要があります。

thunk は、Redux アプリで非同期ロジックを記述するための標準的なアプローチであり、データフェッチによく使用されます。ただし、さまざまなタスクに使用でき、同期ロジックと非同期ロジックの両方を含めることができます。

Thunk の記述

thunk 関数は、Redux ストアの dispatch メソッドと Redux ストアの getState メソッドの 2 つの引数を受け取る関数です。thunk 関数は、アプリケーションコードによって直接呼び出されることはありません。代わりに、store.dispatch() に渡されます。

thunk 関数のディスパッチ
const thunkFunction = (dispatch, getState) => {
// logic here that can dispatch actions or read state
}

store.dispatch(thunkFunction)

thunk 関数には、同期または非同期の任意のロジックを含めることができ、いつでも dispatch または getState を呼び出すことができます。

Redux コードが通常、アクションオブジェクトを手書きする代わりに、アクションオブジェクトをディスパッチ用に生成するためにアクションクリエーターを使用するのと同じように、通常はthunk アクションクリエーターを使用して、ディスパッチされる thunk 関数を生成します。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 は、次のようにアロー関数を使用して記述することもできます。

アロー関数を使用した thunk の記述
export const fetchTodoById = todoId => async dispatch => {
const response = await client.get(`/fakeApi/todo/${todoId}`)
dispatch(todosLoaded(response.todos))
}

いずれの場合も、thunk は、他の Redux アクションをディスパッチするのと同じ方法で、アクションクリエーターを呼び出すことによってディスパッチされます。

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 関連の追加ロジックを記述できます。このロジックには、非同期リクエストやランダム値の生成などの副作用、および複数のアクションのディスパッチや Redux ストアの状態へのアクセスを必要とするロジックを含めることができます。

Redux リデューサーには副作用を含めてはならないため、実際のアプリケーションでは副作用を伴うロジックが必要です。その一部はコンポーネント内に存在する可能性がありますが、UI レイヤーの外に存在する必要があるものもあります。thunk (およびその他の Redux ミドルウェア) は、これらの副作用を配置する場所を提供します。

クリックハンドラーや useEffect フックで非同期リクエストを作成し、結果を処理するなど、コンポーネントに直接ロジックを記述するのが一般的です。ただし、UI レイヤーの外にできるだけ多くのロジックを移動する必要があることがよくあります。これは、ロジックのテスト容易性を向上させたり、UI レイヤーをできるだけ薄く「プレゼンテーショナル」に保ったり、コードの再利用と共有を改善するために行われる場合があります。

ある意味で、thunk は、どの Redux ストアが使用されるかを事前に知る必要なく、Redux ストアと対話する必要があるコードを事前に記述できる抜け穴です。これにより、ロジックが特定の Redux ストアインスタンスにバインドされるのを防ぎ、再利用可能に保ちます。

詳細な説明: Thunk、Connect、および「コンテナコンポーネント」

歴史的に、thunk を使用するもう 1 つの理由は、React コンポーネントを「Redux を意識しない」ようにするためでした。connect API を使用すると、アクションクリエーターを渡して、呼び出されたときにアクションを自動的にディスパッチするように「バインド」できました。コンポーネントは通常、内部で dispatch にアクセスできなかったため、thunk を connect に渡すことで、コンポーネントは親からのコールバックであるか、プレーンな Redux アクションをディスパッチしているか、同期または非同期ロジックを実行する thunk をディスパッチしているか、テストのモック関数であるかを知らなくても、this.props.doSomething() を呼び出すことができるようになりました。

React-Redux hooks API の登場により、状況は変化しました。コミュニティは一般に「コンテナ/プレゼンテーショナル」パターンから移行しており、コンポーネントは useDispatch フックを介して dispatch に直接アクセスできるようになりました。これは、非同期フェッチ + 結果のディスパッチなど、コンポーネント内でより多くのロジックを直接記述できることを意味します。ただし、thunk はコンポーネントが持っていない getState にアクセスできるため、そのロジックをコンポーネントの外に移動することには依然として価値があります。

Thunk のユースケース

thunk は任意のロジックを含めることができる汎用ツールであるため、幅広い目的に使用できます。最も一般的なユースケースは次のとおりです。

  • コンポーネントから複雑なロジックを移動する
  • 非同期リクエストまたはその他の非同期ロジックを作成する
  • 複数のアクションを連続してまたは時間経過に沿ってディスパッチする必要があるロジックを記述する
  • getState にアクセスして、決定を下したり、アクションに他の状態値を含めたりする必要があるロジックを記述する

thunk は「ワンショット」関数であり、ライフサイクルの感覚はありません。また、ディスパッチされた他のアクションを確認することもできません。したがって、一般的にウェブソケットのような永続的な接続を初期化するために使用すべきではなく、他のアクションに応答するために使用することもできません。

thunk は、複雑な同期ロジックや、標準的な AJAX リクエストの作成やリクエスト結果に基づいてアクションをディスパッチするなど、単純から中程度の非同期ロジックに最適です。

Redux Thunk ミドルウェア

thunk 関数をディスパッチするには、Redux ストアの設定の一部として、redux-thunk ミドルウェアが追加されている必要があります。

ミドルウェアの追加

Redux Toolkit の configureStore API は、ストアの作成時に thunk ミドルウェアを自動的に追加するため、通常は追加の設定なしで利用できるはずです。

thunk ミドルウェアをストアに手動で追加する必要がある場合は、セットアッププロセスの一部としてthunk ミドルウェアを applyMiddleware() に渡すことで追加できます。

ミドルウェアはどのように機能するのですか?

まず、Redux ミドルウェアが一般的にどのように機能するかを確認しましょう。

Redux ミドルウェアはすべて、3 つのネストされた関数のシーケンスとして記述されます。:

  • 外側の関数は、{dispatch, getState} を持つ「ストア API」オブジェクトを受け取ります。
  • 中央の関数は、チェーン内の next ミドルウェア (または実際の store.dispatch メソッド) を受け取ります。
  • 内側の関数は、ミドルウェアチェーンを通過する各 action で呼び出されます。

ミドルウェアは、ミドルウェアがそれらの値をインターセプトしてリデューサーに到達させない限り、アクションオブジェクトではない値を store.dispatch() に渡すことを許可するために使用できることに注意することが重要です。

それを念頭に置いて、thunk ミドルウェアの仕様を見てみましょう。

thunk ミドルウェアの実際の実装は非常に短く、約 10 行しかありません。以下に、追加のコメントを追加したソースを示します。

Redux thunk ミドルウェアの実装 (注釈付き)
// 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) を引数としてその関数を呼び出します。
  • 通常の action オブジェクト (またはその他のもの) の場合は、チェーン内の次のミドルウェアに転送されます。

Thunk への設定値の注入

thunk ミドルウェアには、カスタマイズオプションが 1 つあります。セットアップ時に thunk ミドルウェアのカスタムインスタンスを作成し、ミドルウェアに「追加の引数」を注入できます。次に、ミドルウェアは、すべての thunk 関数の 3 番目の引数としてその追加の値を注入します。これは、API メソッドへのハードコードされた依存関係がないように、API サービスレイヤーを thunk 関数に注入するために最も一般的に使用されます。

追加の引数を使用した thunk の設定
import thunkMiddleware from 'redux-thunk'

const serviceApi = createServiceApi('/some/url')

const thunkMiddlewareWithArg = thunkMiddleware.withExtraArgument({ serviceApi })

Redux Toolkit の configureStore は、getDefaultMiddleware でのミドルウェアのカスタマイズの一部としてこれをサポートしています

configureStore を使用した Thunk の追加引数
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())
}
}
}

サンクで状態にアクセスすることを検討するもう1つの理由は、アクションに追加情報を入力するためです。スライスリデューサーが、自身の状態のスライスにはない値を本当に読み取る必要がある場合があります。そのための可能な回避策は、サンクをディスパッチし、状態から必要な値を抽出し、追加情報を含むプレーンアクションをディスパッチすることです。

例:クロススライスデータを含むアクション
// 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構文の方が推奨されます。

非同期リクエストを行う場合、読み込み状態を追跡するのに役立つように、リクエストの前後にアクションをディスパッチするのが標準です。通常、リクエストに「保留中」アクションと、ローディング状態列挙型が「進行中」としてマークされます。リクエストが成功した場合は、結果データとともに「完了」アクションがディスパッチされ、エラー情報を含む「拒否」アクションがディスパッチされます。

ここでのエラー処理は、ほとんどの人が考えるよりもトリッキーになる可能性があります。resPromise.then(dispatchFulfilled).catch(dispatchRejected)をチェーンすると、「完了」アクションの処理中にネットワーク以外のエラーが発生した場合に、「拒否」アクションをディスパッチしてしまう可能性があります。リクエスト自体に関連するエラーのみを処理するようにするには、.then()の2番目の引数を使用する方が適切です。

例:Promiseチェーンを使用した非同期リクエスト
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ブロックがネットワークレベルのエラーのみを処理するようにするには、エラーが発生した場合にサンクが早期にリターンするようにロジックを再構成する必要がある場合があり、「完了」アクションは最後でのみ発生します。

例:async/awaitを使用したエラー処理
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

サンクミドルウェアは、呼び出されたサンク関数が返すものを返すことで、これを行います。

この最も一般的なユースケースは、サンクからPromiseを返すことです。これにより、サンクをディスパッチしたコードは、Promiseを待機して、サンクの非同期処理が完了したことを知ることができます。これは、コンポーネントが追加の処理を調整するためによく使用されます。

例:サンクの結果Promiseを待機する
const onAddTodoClicked = async () => {
await dispatch(saveTodo(todoText))
setTodoText('')
}

これを使用してできる便利なトリックもあります。dispatchにしかアクセスできない場合に、Reduxの状態から1回だけ選択する方法として、サンクを再利用できます。サンクをディスパッチするとサンクの戻り値が返されるため、セレクターを受け入れ、状態とともにセレクターをすぐに呼び出し、結果を返すサンクを記述できます。これは、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の使用

サンクを使用した非同期ロジックの記述は、やや面倒になる可能性があります。通常、各サンクでは、「保留中/完了/拒否」の3つの異なるアクションタイプと、一致するアクションクリエーター、さらに実際サンクアクションクリエーターとサンク関数を定義する必要があります。対処するエラー処理に関するエッジケースもあります。

Redux Toolkitには、createAsyncThunkAPIがあり、これらのアクションの生成、Promiseライフサイクルに基づくディスパッチ、およびエラーの正しい処理のプロセスを抽象化します。これは、部分的なアクションタイプ文字列(pendingfulfilled、およびrejectedのアクションタイプを生成するために使用)と、実際には非同期リクエストを行い、Promiseを返す「ペイロード作成コールバック」を受け入れます。次に、リクエストの前後に、正しい引数を使用して自動的にアクションをディスパッチします。

これは非同期リクエストの特定のユースケースを抽象化したものなので、createAsyncThunkはサンクの考えられるすべてのユースケースに対応しているわけではありません。同期ロジックまたはその他のカスタム動作を記述する必要がある場合は、代わりに手動で「通常の」サンクを記述する必要があります。

サンクアクションクリエーターには、pendingfulfilled、およびrejectedのアクションクリエーターが添付されています。createSliceextraReducersオプションを使用すると、これらのアクションタイプをリッスンし、それに応じてスライスの状態を更新できます。

例:createAsyncThunk
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アプリ向けの目的別データフェッチングおよびキャッシュソリューションであり、データのフェッチを管理するためにいかなるサンクまたはリデューサーを記述する必要もなくなります

RTK Queryは、すべてのリクエストに対して内部的にcreateAsyncThunkを使用し、キャッシュデータのライフタイムを管理するためのカスタムミドルウェアとともに使用します。

まず、アプリが通信するサーバーエンドポイントの定義を含む「APIスライス」を作成します。各エンドポイントは、エンドポイントとリクエストのタイプに基づいて、useGetPokemonByNameQueryのような名前のReactフックを自動生成します。

RTK Query:APIスライス(pokemonSlice.js)
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スライスリデューサーとカスタムミドルウェアをストアに追加します

RTK Query:ストア設定
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フックをコンポーネントにインポートして呼び出します。フックは、コンポーネントがマウントされると自動的にデータをフェッチし、複数のコンポーネントが同じ引数で同じフックを使用している場合は、キャッシュされた結果を共有します。

RTK Query:フェッチングフックの使用
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を試して、それが自分のアプリのデータフェッチングコードを簡素化するのに役立つかどうかを確認することをお勧めします。

詳細情報