メインコンテンツへスキップ
非公式ベータ版翻訳

このページは PageTurner AI で翻訳されました(ベータ版)。プロジェクト公式の承認はありません。 エラーを見つけましたか? 問題を報告 →

カスタムミドルウェアの作成

学習内容
  • カスタムミドルウェアを使用するタイミング
  • ミドルウェアの標準パターン
  • 他のReduxプロジェクトとの互換性を確保する方法

Reduxのミドルウェアは主に以下の目的で使用されます:

  • アクションに対する副作用の生成

  • アクションの変更またはキャンセル

  • dispatchが受け入れる入力の変更

多くのユースケースは最初のカテゴリに該当します。例えばRedux-Sagaredux-observableRTK listener middlewareはすべて、アクションに反応して副作用を生成します。これらの例は、状態変更以外の方法でアクションに対応できることが非常に一般的なニーズであることを示しています。

アクションの変更は、状態や外部入力からの情報でアクションを拡張したり、スロットリング、デバウンス、ゲート処理を行うために使用できます。

dispatchの入力を変更する最も代表的な例はRedux Thunkです。これはアクションを返す関数を呼び出してアクションに変換します。

カスタムミドルウェアを使用するタイミング

ほとんどの場合、実際にはカスタムミドルウェアは必要ありません。ミドルウェアの最も一般的なユースケースは副作用処理であり、Redux向けに副作用を適切にパッケージ化した多くのライブラリが存在し、十分に長く使用されているため、独自実装で遭遇する微妙な問題を回避できます。サーバーサイド状態管理にはRTK Query、その他の副作用にはRTK listener middlewareが良い出発点です。

以下のいずれかのケースでは、カスタムミドルウェアの使用を検討してもよいでしょう:

  1. 単一の非常にシンプルな副作用のみを扱う場合、完全な追加フレームワークを導入する価値がないかもしれません。ただし、アプリケーションが成長した際には独自ソリューションを拡張するのではなく、既存のフレームワークに切り替えるようにしてください。

  2. アクションの変更またはキャンセルが必要な場合

ミドルウェアの標準パターン

アクションに対する副作用の生成

最も一般的なミドルウェアパターンです。RTK listener middlewareの実装例を示します:

const middleware: ListenerMiddleware<S, D, ExtraArgument> =
api => next => action => {
if (addListener.match(action)) {
return startListening(action.payload)
}

if (clearAllListeners.match(action)) {
clearListenerMiddleware()
return
}

if (removeListener.match(action)) {
return stopListening(action.payload)
}

// Need to get this state _before_ the reducer processes the action
let originalState: S | typeof INTERNAL_NIL_TOKEN = api.getState()

// `getOriginalState` can only be called synchronously.
// @see https://github.com/reduxjs/redux-toolkit/discussions/1648#discussioncomment-1932820
const getOriginalState = (): S => {
if (originalState === INTERNAL_NIL_TOKEN) {
throw new Error(
`${alm}: getOriginalState can only be called synchronously`
)
}

return originalState as S
}

let result: unknown

try {
// Actually forward the action to the reducer before we handle listeners
result = next(action)

if (listenerMap.size > 0) {
let currentState = api.getState()
// Work around ESBuild+TS transpilation issue
const listenerEntries = Array.from(listenerMap.values())
for (let entry of listenerEntries) {
let runListener = false

try {
runListener = entry.predicate(action, currentState, originalState)
} catch (predicateError) {
runListener = false

safelyNotifyError(onError, predicateError, {
raisedBy: 'predicate'
})
}

if (!runListener) {
continue
}

notifyListener(entry, action, api, getOriginalState)
}
}
} finally {
// Remove `originalState` store from this scope.
originalState = INTERNAL_NIL_TOKEN
}

return result
}

前半部分では、addListenerclearAllListenersremoveListenerアクションをリッスンし、後で呼び出すリスナーを変更します。

後半部分では、主にアクションを他のミドルウェアやレデューサーに通した後の状態を計算し、元の状態とレデューサーから得られた新しい状態の両方をリスナーに渡します。

副作用はアクションのdispatch後に発生させるのが一般的です。これにより元の状態と新しい状態の両方を考慮できる上、副作用からの相互作用が現在のアクション実行に影響を与えないためです(影響を与えるならば、それは副作用とは言えません)。

アクションの変更/キャンセル、またはdispatch入力の変更

これらのパターンは比較的稀ですが(アクションのキャンセルを除き)、redux thunk middlewareで使用されています:

const middleware: ThunkMiddleware<State, BasicAction, ExtraThunkArg> =
({ dispatch, getState }) =>
next =>
action => {
// The thunk middleware looks for any functions that were passed to `store.dispatch`.
// If this "action" is really a function, call it and return the result.
if (typeof action === 'function') {
// Inject the store's `dispatch` and `getState` methods, as well as any "extra arg"
return action(dispatch, getState, extraArgument)
}

// Otherwise, pass the action down the middleware chain as usual
return next(action)
}

通常、dispatch は JSON 形式のアクションのみを扱うことができます。このミドルウェアは関数形式のアクションも処理できる機能を追加します。また、関数形式アクションの戻り値をdispatch関数の戻り値として渡すことで、dispatch関数自体の戻り値の型を変更します。

互換性のあるミドルウェア作成ルール

原則としてミドルウェアは非常に強力なパターンであり、アクションに対して任意の操作を行えます。ただし既存のミドルウェアは周囲のミドルウェアの動作について前提を持っている場合があり、これらの前提を理解しておくことで、一般的なミドルウェアとの互換性を確保しやすくなります。

ミドルウェア間の接点は主に2つ存在します:

次のミドルウェア呼び出し

nextを呼び出す際、ミドルウェアは何らかの形式のアクションを期待します。明示的に変更する必要がない限り、受け取ったアクションをそのまま渡すべきです。

より重要な点として、一部ミドルウェアはdispatch呼び出しと同じイベントループ内でミドルウェアが実行されることを前提としています。そのためnextは同期的に呼び出す必要があります。

dispatchの戻り値処理

dispatchの戻り値を明示的に変更する必要がない場合、nextから返された値をそのまま返すべきです。戻り値を変更する必要がある場合、ミドルウェアはチェーン内の特定位置に配置する必要があり、他の全ミドルウェアとの互換性を手動で確認し、連携方法を決定しなければなりません。

ここに注意すべき帰結があります:

const middleware: Middleware = api => next => async action => {
const response = next(action)

// Do something after the action hits the reducer
const afterState = api.getState()
if (action.type === 'some/action') {
const data = await fetchData()
api.dispatch(dataFetchedAction(data))
}

return response
}

一見戻り値を変更していないように見えても、async-awaitによって実際にはPromiseを返しています。これはRTK Queryなどのミドルウェアと互換性の問題を引き起こします。

では代わりにどのように実装すべきでしょうか?

const middleware: Middleware = api => next => action => {
const response = next(action)

// Do something after the action hits the reducer
const afterState = api.getState()
if (action.type === 'some/action') {
void loadData(api)
}

return response
}

async function loadData(api) {
const data = await fetchData()
api.dispatch(dataFetchedAction(data))
}

非同期ロジックを別関数に移動し、async-awaitを使用しつつもミドルウェア内でPromiseの解決を待機しないようにします。voidキーワードは、コードの動作に影響を与えずに明示的に待機しないことを示します。

次のステップ

まだ確認していない場合は、ミドルウェアの内部動作を理解するためにRedux理解:ミドルウェアセクションを参照してください。