副作用のアプローチ
- 「副作用」とは何か、そしてそれがReduxにどのように適合するか
- Reduxで副作用を管理するための一般的なツール
- さまざまなユースケースでどのツールを使用するかについての推奨事項
Reduxと副作用
副作用の概要
Reduxストア自体は、非同期ロジックについては何も知りません。ストアが知っているのは、アクションを同期的にディスパッチし、ルートリデューサー関数を呼び出して状態を更新し、何か変更があったことをUIに通知することだけです。非同期処理はすべてストアの外で行われる必要があります。
Reduxリデューサーは「副作用」を決して含んではなりません。「副作用」とは、関数から値を返す以外の場所で確認できる状態または動作の変更のことです。一般的な副作用の種類には、次のようなものがあります。
- 値をコンソールにログ出力する
- ファイルを保存する
- 非同期タイマーを設定する
- AJAX HTTPリクエストを行う
- 関数の外部に存在する状態を変更する、または関数への引数を変更する
- 乱数または一意のランダムID(
Math.random()
やDate.now()
など)を生成する
ただし、実際のアプリでは、これらの種類の処理をどこかで行う必要があります。したがって、リデューサーに副作用を入れることができない場合、どこに入れることができるのでしょうか?
ミドルウェアと副作用
Reduxミドルウェアは、副作用を持つロジックを書くことができるように設計されました.
Reduxミドルウェアは、ディスパッチされたアクションを検出したときにあらゆる処理を実行できます: 何かをログ出力したり、アクションを変更したり、アクションを遅らせたり、非同期呼び出しを行ったりできます。また、ミドルウェアは実際のstore.dispatch
関数を囲むパイプラインを形成するため、ミドルウェアがその値をインターセプトしてリデューサーに到達させない限り、実際にはプレーンなアクションオブジェクトではないものをdispatch
に渡すこともできることを意味します。
ミドルウェアはdispatch
とgetState
にもアクセスできます。これは、ミドルウェアで非同期ロジックを記述し、アクションをディスパッチしてReduxストアと対話する機能も持つことができることを意味します。
このため、Reduxの副作用と非同期ロジックは通常、ミドルウェアを通じて実装されます。
副作用のユースケース
実際には、一般的なReduxアプリでの副作用の最も一般的なユースケースは、サーバーからのデータのフェッチとキャッシュです。
Reduxに固有の別のユースケースは、ディスパッチされたアクションまたは状態の変更に応答して、さらにアクションをディスパッチするなどの追加ロジックを実行するロジックを記述することです。
推奨事項
各ユースケースに最適なツールを使用することをお勧めします(推奨事項の理由と、各ツールの詳細については下記を参照してください)
データフェッチ
- データフェッチとキャッシングのデフォルトのアプローチとしてRTK Queryを使用する
- 何らかの理由でRTKQが完全に適合しない場合は、
createAsyncThunk
を使用する - 他に何も機能しない場合にのみ、手書きのサンクに戻す
- データフェッチにsagaまたはobservableを使用しないでください!
アクション/状態の変化、非同期ワークフローへの反応
- ストアの更新に応答し、長時間実行される非同期ワークフローを記述するためのデフォルトとしてRTKリスナーを使用する
- リスナーがユースケースを十分に解決しない場合にのみ、saga/observableを使用する
状態アクセスを含むロジック
getState
へのアクセスや複数のアクションのディスパッチを含め、複雑な同期ロジックと中程度の非同期ロジックにはサンクを使用する
データフェッチにRTK Queryを使用する理由
「Effectsでのデータフェッチの代替案」に関するReactドキュメントセクションによると、サーバーサイドフレームワークに組み込まれているデータフェッチアプローチ、またはクライアントサイドキャッシュのいずれかを使用する必要があります。データフェッチとキャッシュ管理コードを自分で記述すべきではありません。
RTK Queryは、Reduxベースのアプリケーション用の完全なデータフェッチおよびキャッシングレイヤーとして特別に設計されました。フェッチ、キャッシング、ローディング状態ロジックをすべて管理し、自分でデータフェッチコードを記述した場合に通常忘れられがちまたは扱いにくい多くのエッジケースをカバーし、キャッシュライフサイクル管理が組み込まれています。また、自動生成されたReactフックを介してデータを簡単にフェッチして使用できます。
sagaの複雑さが役に立たず、キャッシング+ローディング状態管理ロジックをすべて自分で記述する必要があるため、データフェッチにsagaを使用することには特に反対します。
リアクティブロジックにリスナーを使用する理由
RTKリスナーミドルウェアは、使いやすくするために意図的に設計しました。標準のasync/await
構文を使用し、最も一般的なリアクティブユースケース(アクションまたは状態の変化への応答、デバウンス、遅延)や、いくつかの高度なケース(子タスクの起動)もカバーしています。バンドルサイズが小さく(〜3K)、Redux Toolkitに含まれており、TypeScriptとうまく連携します。
複数の理由から、ほとんどのリアクティブロジックにsagaまたはobservableを使用することには特に反対します
- saga:generator関数構文とsaga効果の動作を理解する必要があります。追加のアクションをディスパッチする必要があるため、複数のレベルの間接処理を追加します。TypeScriptのサポートが不十分です。ほとんどのReduxユースケースでは、そのパワーと複雑さは単に必要ありません。
- observable:RxJS APIとメンタルモデルを理解する必要があります。デバッグが難しい場合があります。バンドルサイズが大幅に増加する可能性があります
一般的な副作用のアプローチ
Reduxで副作用を管理するための最も低レベルのテクニックは、特定のアクションをリッスンしてロジックを実行する独自のカスタムミドルウェアを記述することです。ただし、これはめったに使用されません。代わりに、ほとんどのアプリは、従来からエコシステムで利用可能な一般的な既製のRedux副作用ミドルウェア(サンク、saga、またはobservable)のいずれかを使用しています。これらのそれぞれに、独自のユースケースとトレードオフがあります。
最近では、公式のRedux Toolkitパッケージに、副作用を管理するための2つの新しいAPIが追加されました。1つはリアクティブロジックを記述するための「リスナー」ミドルウェア、もう1つはサーバー状態のフェッチとキャッシュのためのRTK Queryです。
サンク
Reduxの「サンク」ミドルウェアは、従来から非同期ロジックを記述するための最も広く使用されているミドルウェアでした。
サンクは、関数をdispatch
に渡すことによって機能します。サンクミドルウェアは関数をインターセプトして呼び出し、theThunkFunction(dispatch, getState)
を渡します。サンク関数は、同期/非同期ロジックを実行し、ストアと対話できるようになります。
サンクのユースケース
サンクは、dispatch
とgetState
へのアクセスを必要とする複雑な同期ロジック、または「非同期データをフェッチして結果とともにアクションをディスパッチする」要求などの、中程度の非同期ロジックに最適です。
私たちは従来から、デフォルトのアプローチとしてサンクを推奨しており、Redux Toolkitには特に「リクエストとディスパッチ」のユースケースのためにcreateAsyncThunk
APIが含まれています。その他のユースケースでは、独自のサンク関数を記述できます。
サンクのトレードオフ
- 👍: 関数を記述するだけ。あらゆるロジックを含めることができる
- 👎: ディスパッチされたアクションに応答できない。命令型。キャンセルできない
const thunkMiddleware =
({ dispatch, getState }) =>
next =>
action => {
if (typeof action === 'function') {
return action(dispatch, getState)
}
return next(action)
}
// Original "hand-written" thunk fetch request pattern
const fetchUserById = userId => {
return async (dispatch, getState) => {
// Dispatch "pending" action to help track loading state
dispatch(fetchUserStarted())
// Need to pull this out to have correct error handling
let lastAction
try {
const user = await userApi.getUserById(userId)
// Dispatch "fulfilled" action on success
lastAction = fetchUserSucceeded(user)
} catch (err) {
// Dispatch "rejected" action on failure
lastAction = fetchUserFailed(err.message)
}
dispatch(lastAction)
}
}
// Similar request with `createAsyncThunk`
const fetchUserById2 = createAsyncThunk('fetchUserById', async userId => {
const user = await userApi.getUserById(userId)
return user
})
saga
Redux-Sagaミドルウェアは、従来からサンクに次いで2番目に一般的な副作用ツールでした。これは、バックエンドの「saga」パターンに触発されており、長時間実行されるワークフローはシステム全体でトリガーされるイベントに応答できます。
概念的には、sagaはReduxアプリ内の「バックグラウンドスレッド」と考えることができ、ディスパッチされたアクションをリッスンして追加のロジックを実行する機能を持っています。
sagaはgenerator関数を使用して記述されます。saga関数は副作用の記述を返し、自身を一時停止します。sagaミドルウェアは、副作用を実行し、結果とともにsaga関数を再開する役割を担います。redux-saga
ライブラリには、次のようなさまざまな効果の定義が含まれています
call
:非同期関数を実行し、Promiseが解決されたときに結果を返しますput
:Reduxアクションをディスパッチしますfork
:追加の作業を実行できる「子サガ」を生成します。これは追加のスレッドのようなものです。takeLatest
:指定されたReduxアクションをリッスンし、サガ関数の実行をトリガーします。同じアクションが再度ディスパッチされた場合は、以前に実行されていたサガのコピーをキャンセルします。
サガのユースケース
サガは非常に強力であり、「バックグラウンドスレッド」のような動作や、デバウンス/キャンセルが必要な、非常に複雑な非同期ワークフローに最適です。
サガのユーザーは、サガ関数が望ましい効果の記述のみを返すという事実が、テスト容易性を高める大きな利点であると指摘しています。
サガのトレードオフ
- 👍:サガは、効果の記述のみを返すためテスト可能です。強力な効果モデル、一時停止/キャンセル機能があります。
- 👎:ジェネレーター関数は複雑です。独自のサガ効果APIがあります。サガのテストは実装結果のみをテストすることが多く、サガが変更されるたびに書き直す必要があるため、価値が低くなります。TypeScriptとの相性が良くありません。
import { call, put, takeEvery } from 'redux-saga/effects'
// "Worker" saga: will be fired on USER_FETCH_REQUESTED actions
function* fetchUser(action) {
yield put(fetchUserStarted())
try {
const user = yield call(userApi.getUserById, action.payload.userId)
yield put(fetchUserSucceeded(user))
} catch (err) {
yield put(fetchUserFailed(err.message))
}
}
// "Watcher" saga: starts fetchUser on each `USER_FETCH_REQUESTED` action
function* fetchUserWatcher() {
yield takeEvery('USER_FETCH_REQUESTED', fetchUser)
}
// Can use also use sagas for complex async workflows with "child tasks":
function* fetchAll() {
const task1 = yield fork(fetchResource, 'users')
const task2 = yield fork(fetchResource, 'comments')
yield delay(1000)
}
function* fetchResource(resource) {
const { data } = yield call(api.fetch, resource)
yield put(receiveData(data))
}
Observable
Redux-Observableミドルウェアを使用すると、RxJS observableを使用して「エピック」と呼ばれる処理パイプラインを作成できます。
RxJSはフレームワークに依存しないライブラリであるため、observableのユーザーは、異なるプラットフォーム間でRxJSの使用方法に関する知識を再利用できることが大きなセールスポイントであると指摘しています。さらに、RxJSを使用すると、キャンセルやデバウンスなどのタイミングに関するケースを処理する宣言的なパイプラインを構築できます。
Observableのユースケース
サガと同様に、observableは強力であり、「バックグラウンドスレッド」のような動作や、デバウンス/キャンセルが必要な、非常に複雑な非同期ワークフローに最適です。
Observableのトレードオフ
- 👍:Observableは非常に強力なデータフローモデルです。RxJSの知識はReduxとは別に利用できます。宣言的な構文です。
- 👎:RxJS APIは複雑です。メンタルモデルが難しい場合があります。デバッグが難しい場合があります。バンドルサイズが大きいです。
// Typical AJAX example:
const fetchUserEpic = action$ =>
action$.pipe(
filter(fetchUser.match),
mergeMap(action =>
ajax
.getJSON(`https://api.github.com/users/${action.payload}`)
.pipe(map(response => fetchUserFulfilled(response)))
)
)
// Can write highly complex async pipelines, including delays,
// cancellation, debouncing, and error handling:
const fetchReposEpic = action$ =>
action$.pipe(
filter(fetchReposInput.match),
debounceTime(300),
switchMap(action =>
of(fetchReposStart()).pipe(
concat(
searchRepos(action.payload).pipe(
map(payload => fetchReposSuccess(payload.items)),
catchError(error => of(fetchReposError(error)))
)
)
)
)
)
リスナー
Redux Toolkitには、リアクティブなロジックを処理するためのcreateListenerMiddleware
APIが含まれています。これは特に、サガやobservableの軽量な代替として、バンドルサイズが小さく、APIがシンプルで、TypeScriptのサポートが優れており、同じユースケースの90%を処理することを目的としています。
概念的には、これはReactのuseEffect
フックに似ていますが、Reduxストアの更新に対応するものです。
リスナーミドルウェアを使用すると、アクションと照合してeffect
コールバックを実行するタイミングを決定するエントリを追加できます。thunkと同様に、effect
コールバックは同期または非同期にすることができ、dispatch
とgetState
にアクセスできます。また、非同期ワークフローを構築するためのいくつかのプリミティブを備えたlistenerApi
オブジェクトも受け取ります。例:
condition()
:特定のアクションがディスパッチされるか、状態が変化するまで一時停止します。cancelActiveListeners()
:実行中のエフェクトのインスタンスをキャンセルします。fork()
:追加の作業を実行できる「子タスク」を作成します。
これらのプリミティブを使用すると、リスナーはRedux-Sagaのほぼすべての効果の動作を再現できます。
リスナーのユースケース
リスナーは、軽量なストア永続化、アクションがディスパッチされたときの追加のロジックのトリガー、状態の変化の監視、複雑な長時間実行される「バックグラウンドスレッド」スタイルの非同期ワークフローなど、さまざまなタスクに使用できます。
さらに、リスナーエントリは、特別なadd/removeListener
アクションをディスパッチすることで、実行時に動的に追加および削除できます。これはReactのuseEffect
フックと適切に統合され、コンポーネントのライフタイムに対応する追加の動作を追加するために使用できます。
リスナーのトレードオフ
- 👍:Redux Toolkitに組み込まれています。
async/await
はより親しみやすい構文です。thunkに似ています。軽量なコンセプトとサイズです。TypeScriptと相性が良いです。 - 👎:比較的新しく、まだ「実戦経験」が少ないです。サガ/observableほど柔軟ではありません。
// Create the middleware instance and methods
const listenerMiddleware = createListenerMiddleware()
// Add one or more listener entries that look for specific actions.
// They may contain any sync or async logic, similar to thunks.
listenerMiddleware.startListening({
actionCreator: todoAdded,
effect: async (action, listenerApi) => {
// Run whatever additional side-effect-y logic you want here
console.log('Todo added: ', action.payload.text)
// Can cancel other running instances
listenerApi.cancelActiveListeners()
// Run async logic
const data = await fetchData()
// Use the listener API methods to dispatch, get state,
// unsubscribe the listener, start child tasks, and more
listenerApi.dispatch(todoAdded('Buy pet food'))
}
})
listenerMiddleware.startListening({
// Can match against actions _or_ state changes/contents
predicate: (action, currentState, previousState) => {
return currentState.counter.value !== previousState.counter.value
},
// Listeners can have long-running async workflows
effect: async (action, listenerApi) => {
// Pause until action dispatched or state changed
if (await listenerApi.condition(matchSomeAction)) {
// Spawn "child tasks" that can do more work and return results
const task = listenerApi.fork(async forkApi => {
// Can pause execution
await forkApi.delay(5)
// Complete the child by returning a value
return 42
})
// Unwrap the child result in the listener
const result = await task.result
if (result.status === 'ok') {
console.log('Child succeeded: ', result.value)
}
}
}
})
RTK Query
Redux Toolkitには、Reduxアプリ向けの専用のデータフェッチおよびキャッシュソリューションであるRTK Queryが含まれています。これは、Webアプリケーションでのデータのロードに関する一般的なケースを簡略化するように設計されており、データフェッチとキャッシュのロジックを手動で記述する必要がなくなります。
RTK Queryは、多数の「エンドポイント」で構成されるAPI定義の作成に依存しています。エンドポイントは、データをフェッチする「クエリ」にすることも、サーバーに更新を送信する「ミューテーション」にすることもできます。RTKQは、各キャッシュエントリの使用状況の追跡や、不要になったキャッシュデータの削除など、データのフェッチとキャッシュを内部で管理します。ミューテーションがサーバーの状態を更新するときにデータの自動再フェッチをトリガーするための独自の「タグ」システムを備えています。
Reduxの他の部分と同様に、RTKQはコアではUIに依存せず、任意のUIフレームワークで使用できます。ただし、React統合も組み込まれており、各エンドポイントに対してReactフックを自動的に生成できます。これにより、Reactコンポーネントからデータをフェッチおよび更新するための使い慣れたシンプルなAPIが提供されます。
RTKQは、すぐに使用できるfetch
ベースの実装を提供し、REST APIとの相性が良いです。GraphQL APIでも使用できるほど柔軟であり、任意の非同期関数で動作するように構成することもできます。これにより、Firebase、Supabase、または独自の非同期ロジックなどの外部SDKとの統合が可能になります。
RTKQには、エンドポイントの「ライフサイクルメソッド」など、キャッシュエントリが追加および削除されるときにロジックを実行できる強力な機能もあります。これは、チャットルームの初期データのフェッチ、ソケットへのサブスクライブによるキャッシュの更新に使用される追加のメッセージの取得などのシナリオで使用できます。
RTK Queryのユースケース
RTK Queryは、サーバー状態のデータフェッチとキャッシュのユースケースを解決するために特別に構築されています。
RTK Queryのトレードオフ
- 👍:RTKに組み込まれています。データフェッチとロード状態の管理に一切コード(thunk、セレクター、エフェクト、リデューサー)を記述する必要がありません。TSと相性が良いです。Reduxストアの他の部分に統合されています。組み込みのReactフックがあります。
- 👎:「正規化」されたキャッシュではなく、意図的に「ドキュメント」スタイルのキャッシュです。追加のバンドルサイズコストが一度だけ発生します。
import { createApi, fetchBaseQuery } from '@reduxjs/toolkit/query/react'
import type { Pokemon } from './types'
// Create an API definition using a base URL and expected endpoints
export const api = createApi({
reducerPath: 'pokemonApi',
baseQuery: fetchBaseQuery({ baseUrl: 'https://pokeapi.co/api/v2/' }),
endpoints: builder => ({
getPokemonByName: builder.query<Pokemon, string>({
query: name => `pokemon/${name}`
}),
getPosts: builder.query<Post[], void>({
query: () => '/posts'
}),
addNewPost: builder.mutation<void, Post>({
query: initialPost => ({
url: '/posts',
method: 'POST',
// Include the entire post object as the body of the request
body: initialPost
})
})
})
})
// Export hooks for usage in functional components, which are
// auto-generated based on the defined endpoints
export const { useGetPokemonByNameQuery } = api
export default function App() {
// Using a query hook automatically fetches data and returns query values
const { data, error, isLoading } = useGetPokemonByNameQuery('bulbasaur')
// render UI based on data and loading state
}
その他のアプローチ
カスタムミドルウェア
thunk、サガ、observable、およびリスナーはすべてReduxミドルウェアの形式であるため(また、RTK Queryには独自のカスタムミドルウェアが含まれています)、これらのツールがいずれもユースケースを十分に処理しない場合は、常に独自のカスタムミドルウェアを作成できます。
ただし、アプリのロジックの大部分を管理するための手法としてカスタムミドルウェアを使用しようとすることは特に推奨しません! 一部のユーザーは、アプリの特定の機能ごとに1つずつ、数十のカスタムミドルウェアを作成しようとしました。これは、dispatch
の各呼び出しの一部として各ミドルウェアを実行する必要があるため、大きなオーバーヘッドを追加します。代わりに、thunkやリスナーなどの汎用ミドルウェアを使用することをお勧めします。このミドルウェアは、さまざまなロジックのチャンクを処理できる単一のミドルウェアインスタンスを追加します。
const delayedActionMiddleware = storeAPI => next => action => {
if (action.type === 'todos/todoAdded') {
setTimeout(() => {
// Delay this action by one second
next(action)
}, 1000)
return
}
return next(action)
}
Websocket
多くのアプリでは、主にサーバーからのストリーミング更新を受信するために、websocketまたはその他の形式の永続的な接続を使用しています。
一般的に、Reduxアプリでのほとんどのwebsocketの使用は、いくつかの理由から、カスタムミドルウェア内で行うことをお勧めします。
- ミドルウェアはアプリケーションのライフタイムの間存在します。
- ストア自体と同様に、アプリ全体で使用できる特定の接続のインスタンスが1つだけ必要な場合があります。
- ミドルウェアは、ディスパッチされたすべてのアクションを確認し、自身でアクションをディスパッチできます。つまり、ミドルウェアはディスパッチされたアクションを取得してwebsocket経由で送信されるメッセージに変換し、websocket経由でメッセージを受信したときに新しいアクションをディスパッチできます。
- websocket接続インスタンスはシリアライズできないため、ストアの状態自体には属しません。
アプリケーションのニーズに応じて、ミドルウェアの初期化プロセスの一部としてソケットを作成したり、初期化アクションをディスパッチすることによりミドルウェアでオンデマンドでソケットを作成したり、別のモジュールファイルで作成して他の場所からアクセスしたりできます。
Websocketは、RTK Queryライフサイクルコールバックでも使用できます。このコールバックでは、メッセージに応答してRTKQキャッシュに更新を適用できます。
XState
ステートマシンは、システムの可能な既知の状態と各状態間の可能な遷移を定義したり、遷移が発生したときに副作用をトリガーしたりするのに非常に役立ちます。
Reduxリデューサーは真の有限ステートマシンとして記述できますが、RTKにはこれを支援するものは何も含まれていません。実際には、ディスパッチされたアクションを状態の更新方法を決定するためだけに気にする部分的なステートマシンになる傾向があります。リスナー、サガ、およびobservableは「ディスパッチ後に副作用を実行する」という側面に使用できますが、副作用が特定の時間にのみ実行されるようにするために、より多くの作業が必要になる場合があります。
XStateは、真のステートマシンを定義し、それを実行するための強力なライブラリです。イベントに基づく状態遷移の管理や、関連する副作用のトリガーなどを含みます。また、グラフィカルエディタを介してステートマシンの定義を作成するための関連ツールも備えており、作成した定義はXStateロジックにロードして実行できます。
現在、XStateとRedux間の公式な統合はありませんが、XStateマシンをReduxリデューサーとして使用することは可能です。また、XStateの開発者たちは、XStateをReduxの副作用ミドルウェアとして使用するデモとなる便利なPOCを作成しています。
詳細情報
- プレゼンテーション: Redux非同期ロジックの進化
- ミドルウェアと副作用の理由
- ドキュメントとチュートリアル
- 記事と比較