副作用の扱い方
このページは PageTurner AI で翻訳されました(ベータ版)。プロジェクト公式の承認はありません。 エラーを見つけましたか? 問題を報告 →
- 「副作用」とは何か、Reduxにおける位置付け
- Reduxで副作用を管理する一般的なツール
- 異なるユースケースに適したツール選択の推奨事項
Reduxと副作用
副作用の概要
Reduxストア自体は非同期ロジックについて何も知りません。同期的にアクションをディスパッチし、ルートリデューサー関数を呼び出して状態を更新し、何かが変更されたことをUIに通知することしかできません。非同期処理はストアの外部で行われる必要があります。
Redux reducerは決して「副作用」を含んではいけません。「副作用」とは、関数から値を返すこと以外で観測可能な状態や動作の変化を指します。一般的な副作用の例としては以下があります:
-
コンソールへの値の出力
-
ファイルの保存
-
非同期タイマーの設定
-
AJAX HTTPリクエストの実行
-
関数外部の状態変更や引数の変更
-
乱数や一意な ID の生成(例:
Math.random()やDate.now())
しかし実際のアプリケーションでは、こうした処理をどこかで行う必要があります。では reducer に副作用を置けない場合、どこに置けるのでしょうか?
ミドルウェアと副作用
Redux ミドルウェアは副作用を持つロジックを記述できるように設計されています。
Reduxミドルウェアはディスパッチされたアクションを検知するとあらゆる処理を実行できます:ログ出力、アクションの変更、アクションの遅延、非同期呼び出しなど。またミドルウェアは実際のstore.dispatch関数を囲むパイプラインを形成するため、ミドルウェアが値をインターセプトしてreducerに到達させない限り、プレーンなアクションオブジェクトではないものをdispatchに渡すことも可能です。
ミドルウェアはdispatchとgetStateにもアクセスできます。つまり非同期ロジックをミドルウェア内に記述しつつ、アクションをディスパッチしてReduxストアとやり取りすることが可能です。
このためReduxの副作用と非同期ロジックは通常ミドルウェアを通じて実装されます。
副作用のユースケース
実際には、典型的なReduxアプリケーションで最も一般的な副作用のユースケースはサーバーからのデータ取得とキャッシュです。
Redux固有の別のユースケースとしては、ディスパッチされたアクションや状態変化に反応して追加ロジック(さらなるアクションのディスパッチなど)を実行するロジックの記述があります。
推奨事項
各ユースケースに最適なツールの使用を推奨します(推奨理由と各ツールの詳細は後述):
データ取得
- データ取得とキャッシュにはデフォルトでRTK Queryを使用
- RTK Queryが適さない場合のみ
createAsyncThunkを使用 - 他に手段がない場合のみ手書きのthunkに頼る
- データ取得にsagaやobservableは使用しない
アクション/状態変化への反応、非同期ワークフロー
- ストア更新への応答や長時間実行される非同期ワークフローにはRTK listenersをデフォルトで使用
- listenersでユースケースが解決できない場合のみsaga/observableを使用
状態アクセスを伴うロジック
- 複雑な同期処理や中程度の非同期ロジックにはthunkを使用(
getStateアクセスや複数アクションのディスパッチを含む)
データ取得にRTK Queryを推奨する理由
Reactドキュメントの「Effect内データ取得の代替手段」に従い、データ取得にはサーバーサイドフレームワーク組み込みのアプローチかクライアントサイドキャッシュを使用すべきです。データ取得とキャッシュ管理コードを自前で書くべきではありません。
RTK QueryはReduxベースアプリケーション向けの完全なデータ取得・キャッシュ層として設計されました。取得・キャッシュ・ローディング状態管理ロジックをすべて代行し、自前実装では忘れがちなエッジケースやハンドリング困難なケースをカバー。キャッシュライフサイクル管理も組み込まれています。自動生成されるReactフックを通じたデータ取得・利用も簡素化します。
sagaをデータ取得に使わないよう特にお勧めするのは、sagaの複雑性がメリットにならず、キャッシュとローディング状態管理ロジックを依然として自前実装する必要があるためです。
リアクティブロジックにlistenerを推奨する理由
RTK リスナーミドルウェアは意図的に使いやすい設計になっています。標準的な async/await 構文を使用し、アクションや状態変化への応答、デバウンス、遅延といった一般的なリアクティブユースケースのほとんどをカバーし、さらに子タスクの起動といった高度なケースにも対応しています。バンドルサイズは小さく(約3K)、Redux Toolkitに組み込まれており、TypeScriptでもシームレスに動作します。
ほとんどのリアクティブロジックに対して、サガやオブザーバブルの使用は以下の複数の理由から特に推奨しません:
-
サガ: ジェネレーター関数の構文とサガエフェクトの挙動を理解する必要がある、追加アクションのディスパッチによる間接レイヤーの増加、TypeScriptサポートが不十分、そしてReduxユースケースの大半ではその高度な機能と複雑性が過剰である
-
オブザーバブル: RxJSのAPIとメンタルモデルの習得が必要、デバッグが困難、バンドルサイズが大幅に増加する可能性がある
一般的な副作用アプローチ
Reduxで副作用を管理する最も低レベルな手法は、特定のアクションを監視してロジックを実行するカスタムミドルウェアを自作することですが、これは稀です。代わりに、歴史的にほとんどのアプリではエコシステムから入手可能な一般的なRedux副作用ミドルウェア(サンク、サガ、オブザーバブル)のいずれかを使用してきました。それぞれに異なるユースケースとトレードオフがあります。
最近では公式Redux Toolkitパッケージが副作用管理のための新たなAPIを追加しました:リアクティブロジック記述用の「リスナー」ミドルウェアと、サーバー状態取得・キャッシュ用の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
})
サガ
Redux-Sagaミドルウェアは、サンクに次いで副作用管理で2番目に一般的なツールです。バックエンドの「サガ」パターンに着想を得ており、長期実行ワークフローがシステム全体でトリガーされるイベントに応答できます。
概念的には、サガをReduxアプリ内の「バックグラウンドスレッド」と見なせます。ディスパッチされたアクションを監視し、追加ロジックを実行する能力を持ちます。
サガはジェネレーター関数で記述されます。副作用の「記述」を返して一時停止し、サガミドルウェアが副作用を実行した後、結果とともに再開します。redux-sagaライブラリには以下のようなエフェクト定義があります:
-
call: 非同期関数を実行し、プロミス解決時に結果を返す -
put: Reduxアクションをディスパッチ -
fork: 追加作業が可能な「子サガ」を生成(追加スレッド類似) -
takeLatest: 指定Reduxアクションを監視→サガ関数実行トリガー→再ディスパッチ時に既存実行サガをキャンセル
サガのユースケース
サガは非常に強力で、「バックグラウンドスレッド」型の挙動やデバウンス/キャンセルが必要な高度に複雑な非同期ワークフローに最適です。
Sagaのユーザーは、saga関数が単に希望するエフェクトの記述を返すだけである点を、テスト容易性の大きな利点として挙げることがよくあります。
Sagaのトレードオフ
-
👍: エフェクトの記述のみを返すためテスト容易、強力なエフェクトモデル、一時停止/キャンセル機能
-
👎: ジェネレーター関数は複雑、固有のsagaエフェクトAPI、sagaテストは実装結果のみをテストすることが多く変更のたびに書き直す必要があり価値が低下、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))
}
Observables
Redux-Observableミドルウェアは、RxJSオブザーバブルを使用して「epic」と呼ばれる処理パイプラインを作成できます。
RxJSはフレームワークに依存しないライブラリであるため、オブザーバブルのユーザーは異なるプラットフォーム間で知識を再利用できる点を主要な売りにしています。さらにRxJSは、キャンセルやデバウンスのようなタイミング制御を宣言的にパイプライン化できます。
Observableのユースケース
Sagaと同様に、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)))
)
)
)
)
)
Listeners
Redux ToolkitにはcreateListenerMiddleware APIが含まれており、「リアクティブ」ロジックを処理します。これはsagaやobservableに比べて軽量な代替手段として設計されており、同等のユースケースの90%をカバーしつつ、バンドルサイズが小さく、APIがシンプルで、TypeScriptサポートも優れています。
概念的にはReactのuseEffectフックに似ていますが、Reduxストアの更新用です。
リスナーミドルウェアでは、effectコールバックを実行するタイミングを決定するためにアクションにマッチするエントリを追加できます。Thunkと同様に、effectコールバックは同期/非同期に対応し、dispatchやgetStateにアクセス可能です。また非同期ワークフロー構築のためのプリミティブを備えたlistenerApiオブジェクトも利用できます:
-
condition(): 特定のアクションがディスパッチされるか状態変化が発生するまで一時停止 -
cancelActiveListeners(): 実行中のエフェクトインスタンスをキャンセル -
fork(): 追加作業が可能な「子タスク」を作成
これらのプリミティブにより、リスナーはRedux-Sagaのエフェクト挙動をほぼ全て再現できます。
Listenerのユースケース
リスナーは、軽量なストア永続化、アクション発行時の追加ロジックトリガー、状態変化の監視、複雑な長時間実行型の「バックグラウンドスレッド」スタイルの非同期ワークフローなど、多様なタスクに使用できます。
さらに、実行時に特別なadd/removeListenerアクションをディスパッチすることで、リスナーエントリを動的に追加/削除可能です。これはReactのuseEffectフックとうまく連携し、コンポーネントのライフタイムに対応した追加動作に利用できます。
Listenerのトレードオフ
-
👍: Redux Toolkitに組み込み、
async/await構文が親しみやすい、thunkと類似、軽量なコンセプトとサイズ、TypeScriptとの相性が良い -
👎: 比較的新しく「実戦検証」が十分でない、saga/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にはRTK Queryが含まれており、Reduxアプリ向けに特化したデータ取得・キャッシュソリューションです。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/セレクター/エフェクト/リデューサー)が不要。TypeScriptと相性良好。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、Saga、Observable、ListenerはいずれもReduxミドルウェアの形態であり(RTK Queryも独自のミドルウェアを含む)、既存ツールでユースケースを十分に処理できない場合には常にカスタムミドルウェアの作成が可能です。
ただしアプリケーションロジックの大部分をカスタムミドルウェアで管理することは非推奨です。特定機能ごとに個別のミドルウェアを作成するユーザーもいますが、各ミドルウェアはdispatch呼び出しのたびに実行されるため、パフォーマンスオーバーヘッドが顕著です。代わりにthunkやlistenerのような汎用ミドルウェアを使用し、単一のミドルウェアインスタンスで複数のロジックを処理する方が効率的です。
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利用はカスタムミドルウェア内に実装することを推奨します。理由は以下の通りです:
-
ミドルウェアはアプリケーションのライフタイム全体にわたって存在する
-
ストア自体と同様に、アプリケーション全体で使用できる単一の接続インスタンスで十分な場合が多い
-
ミドルウェアはすべてのディスパッチされたアクションを監視でき、自身もアクションをディスパッチできる。つまり、ミドルウェアはディスパッチされたアクションをWebSocket経由で送信するメッセージに変換したり、WebSocket経由でメッセージを受信した際に新しいアクションをディスパッチしたりできる
-
WebSocket接続インスタンスはシリアライズ不可能なため、ストア状態自体に含めるべきではありません
アプリケーションの要件に応じて、ミドルウェア初期化プロセスでソケットを作成する、初期化アクションのディスパッチ時にオンデマンドで作成する、別モジュールファイルで作成して他からアクセス可能にするなどの方法が選択できます。
WebSocketはRTK Queryのライフサイクルコールバックでも利用可能で、メッセージ受信時にRTKQキャッシュを更新する処理に応用できます。
XState
ステートマシンは、システムの取りうる既知の状態と状態間遷移を定義し、遷移時に副作用をトリガーするのに非常に有用です。
Reduxのreducerは真の有限状態機械として記述できますが、RTKはこれを支援する機能を提供していません。実際には、状態更新を決定する際にディスパッチされたアクションのみを考慮する「部分的な」状態機械として実装される傾向があります。リスナー、saga、observableは「ディスパッチ後の副作用実行」に対応できますが、特定のタイミングでのみ副作用を実行させるために追加作業が必要になる場合があります。
XStateは真の状態機械を定義・実行する強力なライブラリで、イベントに基づく状態遷移の管理や関連副作用のトリガーを含みます。グラフィカルエディタによる状態機械定義を作成する関連ツールも備えており、定義をXStateロジックに読み込んで実行できます。
XStateとReduxの公式統合は現時点で存在しませんが、XStateマシンをRedux reducerとして使用することは可能です。XState開発者はRedux副作用ミドルウェアとしてXStateを使用する概念実証を公開しています:
参考情報
-
プレゼンテーション: Redux非同期ロジックの進化
-
ミドルウェアと副作用の背景:
-
ドキュメントとチュートリアル:
-
記事と比較分析: