本文へスキップ

createListenerMiddleware

概要

追加のロジックを含む「effect」コールバックと、ディスパッチされたアクションや状態の変化に基づいてそのコールバックを実行するタイミングを指定する方法を含む「リスナー」エントリを定義できるReduxミドルウェアです。

sagaやobservableなど、より広く使用されているRedux非同期ミドルウェアの軽量な代替として意図されています。複雑さと概念のレベルにおいてthunkと似ていますが、いくつかの一般的なsagaの使用パターンを複製するために使用できます。

概念的には、これはReactのuseEffectフックに似ていると考えられます。ただし、コンポーネントのプロップス/状態の更新ではなく、Reduxストアの更新に応じてロジックを実行します。

リスナーエフェクトコールバックは、thunkと同様にdispatchgetStateにアクセスできます。リスナーは、takeconditionpauseforkunsubscribeなどの非同期ワークフロー関数のセットも受信し、より複雑な非同期ロジックを記述できます。

リスナーは、セットアップ時にlistenerMiddleware.startListening()を呼び出すことで静的に定義するか、特別なdispatch(addListener())dispatch(removeListener())アクションを使用して実行時に動的に追加および削除できます。

基本的な使用方法

import { configureStore, createListenerMiddleware } from '@reduxjs/toolkit'

import todosReducer, {
todoAdded,
todoToggled,
todoDeleted,
} from '../features/todos/todosSlice'

// 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()

// Pause until action dispatched or state changed
if (await listenerApi.condition(matchSomeAction)) {
// Use the listener API methods to dispatch, get state,
// unsubscribe the listener, start child tasks, and more
listenerApi.dispatch(todoAdded('Buy pet food'))

// 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
})

const result = await task.result
// Unwrap the child result in the listener
if (result.status === 'ok') {
// Logs the `42` result value that was returned
console.log('Child succeeded: ', result.value)
}
}
},
})

const store = configureStore({
reducer: {
todos: todosReducer,
},
// Add the listener middleware to the store.
// NOTE: Since this can receive actions with functions inside,
// it should go before the serializability check middleware
middleware: (getDefaultMiddleware) =>
getDefaultMiddleware().prepend(listenerMiddleware.middleware),
})

createListenerMiddleware

ミドルウェアのインスタンスを作成します。これは、configureStoremiddlewareパラメーターを介してストアに追加する必要があります。

const createListenerMiddleware = (options?: CreateMiddlewareOptions) =>
ListenerMiddlewareInstance

interface CreateListenerMiddlewareOptions<ExtraArgument = unknown> {
extra?: ExtraArgument
onError?: ListenerErrorHandler
}

type ListenerErrorHandler = (
error: unknown,
errorInfo: ListenerErrorInfo,
) => void

interface ListenerErrorInfo {
raisedBy: 'effect' | 'predicate'
}

ミドルウェアオプション

  • extra:各リスナーのlistenerApiパラメーターに挿入されるオプションの「追加引数」。Redux Thunkミドルウェアの「追加引数」と等価です。
  • onErrorlistenerによって発生した同期および非同期エラー、およびpredicateによって発生した同期エラーで呼び出されるオプションのエラーハンドラー。

リスナーミドルウェアインスタンス

createListenerMiddlewareから返される「リスナーミドルウェアインスタンス」は、createSliceによって生成される「スライス」オブジェクトと同様のオブジェクトです。インスタンスオブジェクトは、実際のReduxミドルウェア自体ではありません。むしろ、ミドルウェア内にリスナーエントリを追加および削除するために使用されるミドルウェアといくつかのインスタンスメソッドが含まれています。

interface ListenerMiddlewareInstance<
State = unknown,
Dispatch extends ThunkDispatch<State, unknown, UnknownAction> = ThunkDispatch<
State,
unknown,
UnknownAction
>,
ExtraArgument = unknown,
> {
middleware: ListenerMiddleware<State, Dispatch, ExtraArgument>
startListening: (options: AddListenerOptions) => Unsubscribe
stopListening: (
options: AddListenerOptions & UnsubscribeListenerOptions,
) => boolean
clearListeners: () => void
}

middleware

実際のReduxミドルウェア。configureStore.middlewareオプションを介してReduxストアに追加します。

リスナーミドルウェアは関数を含む「追加」および「削除」アクションを受信できるため、シリアライズ可能性チェックミドルウェアの前に配置されるように、通常はチェーンの最初のミドルウェアとして追加する必要があります。

const store = configureStore({
reducer: {
todos: todosReducer,
},
// Add the listener middleware to the store.
// NOTE: Since this can receive actions with functions inside,
// it should go before the serializability check middleware
middleware: (getDefaultMiddleware) =>
getDefaultMiddleware().prepend(listenerMiddleware.middleware),
})

startListening

ミドルウェアに新しいリスナーエントリを追加します。通常、アプリケーションのセットアップ中に新しいリスナーを「静的に」追加するために使用されます。

const startListening = (options: AddListenerOptions) => UnsubscribeListener

interface AddListenerOptions {
// Four options for deciding when the listener will run:

// 1) Exact action type string match
type?: string

// 2) Exact action type match based on the RTK action creator
actionCreator?: ActionCreator

// 3) Match one of many actions using an RTK matcher
matcher?: Matcher

// 4) Return true based on a combination of action + state
predicate?: ListenerPredicate

// The actual callback to run when the action is matched
effect: (action: Action, listenerApi: ListenerApi) => void | Promise<void>
}

type ListenerPredicate<Action extends ReduxAction, State> = (
action: Action,
currentState?: State,
originalState?: State,
) => boolean

type UnsubscribeListener = (
unsubscribeOptions?: UnsubscribeListenerOptions,
) => void

interface UnsubscribeListenerOptions {
cancelActive?: true
}

リスナーの実行時期を決定するための4つのオプション(typeactionCreatormatcherpredicate)のうち、正確に1つを指定する必要があります。アクションがディスパッチされるたびに、各リスナーは、現在のアクションと提供された比較オプションとの比較に基づいて実行する必要があるかどうかがチェックされます。

これらはすべて許容されます。

// 1) Action type string
listenerMiddleware.startListening({ type: 'todos/todoAdded', effect })
// 2) RTK action creator
listenerMiddleware.startListening({ actionCreator: todoAdded, effect })
// 3) RTK matcher function
listenerMiddleware.startListening({
matcher: isAnyOf(todoAdded, todoToggled),
effect,
})
// 4) Listener predicate
listenerMiddleware.startListening({
predicate: (action, currentState, previousState) => {
// return true when the listener should run
},
effect,
})

predicateオプションは、実際のアクションに関係なく、「state.xは変更されましたか?」や「state.xの現在の値は特定の基準に合致しますか?」など、状態関連のチェックに対してのみマッチングを許可します。

RTKに含まれる「matcher」ユーティリティ関数は、matcherまたはpredicateオプションのいずれかとして許容されます。

戻り値は、このリスナーを削除するunsubscribe()コールバックです。デフォルトでは、登録解除はリスナーのアクティブなインスタンスをキャンセルしません。ただし、実行中のインスタンスをキャンセルするには、{cancelActive: true}を渡すこともできます。

リスナーエントリを追加しようとしましたが、この正確な関数参照を持つ別のエントリが既に存在する場合は、新しいエントリは追加されず、既存のunsubscribeメソッドが返されます。

effectコールバックは、現在のアクションを最初の引数として、createAsyncThunkの「thunk API」オブジェクトと同様の「リスナーAPI」オブジェクトも受信します。

すべてのリスナー述語とコールバックは、ルートreducerが既にアクションを処理して状態を更新したにチェックされます。listenerApi.getOriginalState()メソッドを使用して、このリスナーをトリガーしたアクションが処理される前に存在していた状態値を取得できます。

stopListening

指定されたリスナーエントリを削除します。

startListening()と同じ引数を受け入れます。listenerの関数参照と提供されたactionCreator/matcher/predicate関数またはtype文字列を比較して、既存のリスナーエントリをチェックします。

デフォルトでは、実行中のアクティブなインスタンスはキャンセルされません。ただし、実行中のインスタンスをキャンセルするには、{cancelActive: true}を渡すこともできます。

const stopListening = (
options: AddListenerOptions & UnsubscribeListenerOptions,
) => boolean

interface UnsubscribeListenerOptions {
cancelActive?: true
}

リスナーエントリが削除された場合はtrueを、提供された入力と一致するサブスクリプションが見つからない場合はfalseを返します。

// Examples:
// 1) Action type string
listenerMiddleware.stopListening({
type: 'todos/todoAdded',
listener,
cancelActive: true,
})
// 2) RTK action creator
listenerMiddleware.stopListening({ actionCreator: todoAdded, effect })
// 3) RTK matcher function
listenerMiddleware.stopListening({ matcher, effect, cancelActive: true })
// 4) Listener predicate
listenerMiddleware.stopListening({ predicate, effect })

clearListeners

現在のすべてのリスナーエントリを削除します。また、それらのリスナーのアクティブな実行中のインスタンスもすべてキャンセルします。

これは、単一のミドルウェアまたはストアインスタンスが複数のテストで使用される可能性のあるテストシナリオ、およびいくつかのアプリケーションクリーンアップ状況で最も役立ちます。

const clearListeners = () => void;

Action Creators

リスナーインスタンスのメソッドを直接呼び出すことでリスナーを追加および削除することに加えて、実行時に特別な「追加」および「削除」アクションをディスパッチすることで、リスナーを動的に追加および削除できます。これらは、標準のRTK生成Action CreatorとしてメインのRTKパッケージからエクスポートされます。

addListener

パッケージからインポートされた標準的なRTK Action Creator。このアクションをディスパッチすると、ミドルウェアは実行時に新しいリスナーを動的に追加します。startListening()と同じオプションを正確に受け入れます。

このアクションをディスパッチすると、dispatchからunsubscribe()コールバックが返されます。

// Per above, provide `predicate` or any of the other comparison options
const unsubscribe = store.dispatch(addListener({ predicate, effect }))

removeListener

パッケージからインポートされた標準的なRTK Action Creator。このアクションをディスパッチすると、ミドルウェアは実行時にリスナーを動的に削除します。stopListening()と同じ引数を受け入れます。

デフォルトでは、実行中のアクティブなインスタンスはキャンセルされません。ただし、実行中のインスタンスをキャンセルするには、{cancelActive: true}を渡すこともできます。

リスナーエントリが削除された場合はtrueを、提供された入力と一致するサブスクリプションが見つからない場合はfalseを返します。

const wasRemoved = store.dispatch(
removeListener({ predicate, effect, cancelActive: true }),
)

clearAllListeners

パッケージからインポートされた標準的なRTK Action Creator。このアクションをディスパッチすると、ミドルウェアは現在のすべてのリスナーエントリを削除します。また、それらのリスナーのアクティブな実行中のインスタンスもすべてキャンセルします。

store.dispatch(clearAllListeners())

リスナーAPI

listenerApiオブジェクトは、各リスナーコールバックの2番目の引数です。リスナーのロジック内のどこでも呼び出すことができるいくつかのユーティリティ関数を含んでいます。

export interface ListenerEffectAPI<
State,
Dispatch extends ReduxDispatch<UnknownAction>,
ExtraArgument = unknown,
> extends MiddlewareAPI<Dispatch, State> {
// NOTE: MiddlewareAPI contains `dispatch` and `getState` already

/**
* Returns the store state as it existed when the action was originally dispatched, _before_ the reducers ran.
* This function can **only** be invoked **synchronously**, it throws error otherwise.
*/
getOriginalState: () => State
/**
* Removes the listener entry from the middleware and prevent future instances of the listener from running.
* It does **not** cancel any active instances.
*/
unsubscribe(): void
/**
* It will subscribe a listener if it was previously removed, noop otherwise.
*/
subscribe(): void
/**
* Returns a promise that resolves when the input predicate returns `true` or
* rejects if the listener has been cancelled or is completed.
*
* The return value is `true` if the predicate succeeds or `false` if a timeout is provided and expires first.
*/
condition: ConditionFunction<State>
/**
* Returns a promise that resolves when the input predicate returns `true` or
* rejects if the listener has been cancelled or is completed.
*
* The return value is the `[action, currentState, previousState]` combination that the predicate saw as arguments.
*
* The promise resolves to null if a timeout is provided and expires first.
*/
take: TakePattern<State>
/**
* Cancels all other running instances of this same listener except for the one that made this call.
*/
cancelActiveListeners: () => void
/**
* Cancels the listener instance that made this call.
*/
cancel: () => void
/**
* Throws a `TaskAbortError` if this listener has been cancelled
*/
throwIfCancelled: () => void
/**
* An abort signal whose `aborted` property is set to `true`
* if the listener execution is either aborted or completed.
* @see https://developer.mozilla.org/en-US/docs/Web/API/AbortSignal
*/
signal: AbortSignal
/**
* Returns a promise that resolves after `timeoutMs` or
* rejects if the listener has been cancelled or is completed.
*/
delay(timeoutMs: number): Promise<void>
/**
* Queues in the next microtask the execution of a task.
*/
fork<T>(executor: ForkedTaskExecutor<T>): ForkedTask<T>
/**
* Returns a promise that resolves when `waitFor` resolves or
* rejects if the listener has been cancelled or is completed.
* @param promise
*/
pause<M>(promise: Promise<M>): Promise<M>
extra: ExtraArgument
}

これらはいくつかのカテゴリに分類できます。

ストア相互作用メソッド

  • dispatch: Dispatch:標準的なstore.dispatchメソッド
  • getState: () => State:標準的なstore.getStateメソッド
  • getOriginalState: () => State:reducerが実行されるに、アクションが最初にディスパッチされた時点でのストアの状態を返します。(注記:メモリリークを回避するために、このメソッドは同期的に、最初のディスパッチ呼び出しスタック中にのみ呼び出すことができます。非同期的に呼び出すとエラーが発生します。)
  • extra: unknown:ミドルウェアのセットアップ時に提供された場合、「追加引数」。

dispatchgetStateはthunkとまったく同じです。getOriginalStateを使用して、リスナーが開始される前の元の状態を比較できます。

extraを使用して、作成時にAPIサービスレイヤーなどの値をミドルウェアに挿入し、ここでアクセスできます。

リスナーサブスクリプション管理

  • unsubscribe: () => void: リスナーエントリをミドルウェアから削除し、リスナーの将来のインスタンスの実行を防ぎます。(これは、アクティブなインスタンスをキャンセルするものではありません。)
  • subscribe: () => void: 以前削除されていた場合、リスナーエントリを再登録します。現在登録されている場合は何もしません。
  • cancelActiveListeners: () => void: この呼び出しを行ったリスナーを除き、同じリスナーの他のすべての実行中のインスタンスをキャンセルします。(キャンセルは、他のインスタンスがtake/cancel/pause/delayなどのキャンセル対応APIを使用して一時停止されている場合にのみ意味のある効果を持ちます。「使用方法」セクションの「キャンセルとタスク管理」を参照してください。)
  • cancel: () => void: この呼び出しを行ったリスナーのインスタンスをキャンセルします。
  • throwIfCancelled: () => void: 現在のリスナーインスタンスがキャンセルされた場合、TaskAbortErrorをスローします。
  • signal: AbortSignal: リスナーの実行が中断または完了した場合、abortedプロパティがtrueに設定されるAbortSignal

このリスナーの動的な登録解除と再登録により、より複雑な非同期ワークフローが可能になります。例えば、リスナーの開始時にlistenerApi.unsubscribe()を呼び出すことで、重複する実行インスタンスを回避したり、listenerApi.cancelActiveListeners()を呼び出すことで、最新のインスタンスのみが完了できるようにすることができます。

条件付きワークフローの実行

  • take: (predicate: ListenerPredicate, timeout?: number) => Promise<[Action, State, State] | null>: predicatetrueを返すまで解決を待つPromiseを返します。戻り値は、述語が引数として見た[action, currentState, previousState]の組み合わせです。timeoutが指定され、先に期限切れになった場合は、Promiseはnullに解決されます。
  • condition: (predicate: ListenerPredicate, timeout?: number) => Promise<boolean>: takeに似ていますが、述語が成功した場合はtruetimeoutが指定され、先に期限切れになった場合はfalseに解決されます。これにより、非同期ロジックが一時停止し、条件が発生するのを待つことができます。使用方法の詳細については、以下の「非同期ワークフローの記述」を参照してください。
  • delay: (timeoutMs: number) => Promise<void>: タイムアウト後に解決されるキャンセル対応のPromiseを返します。期限切れ前にキャンセルされた場合は拒否されます。
  • pause: (promise: Promise<T>) => Promise<T>: 任意のPromiseを受け入れ、引数のPromiseで解決されるか、解決前にキャンセルされた場合は拒否されるキャンセル対応のPromiseを返します。

これらのメソッドは、将来ディスパッチされたアクションと状態の変化に基づいて条件付きロジックを記述する機能を提供します。両方とも、ミリ秒単位のオプションのtimeoutを受け入れます。

takeはタイムアウトした場合は[action, currentState, previousState]タプルまたはnullに解決され、conditionは成功した場合はtrue、タイムアウトした場合はfalseに解決されます。

takeは「アクションを待ってその内容を取得する」ことを目的としており、conditionif (await condition(predicate))のようなチェックを目的としています。

これらのメソッドはどちらもキャンセル対応であり、一時停止中にリスナーインスタンスがキャンセルされた場合はTaskAbortErrorをスローします。

takeconditionの両方が、**次のアクションがディスパッチされた後**にのみ解決されることに注意してください。述語が現在の状態に対してtrueを返す場合でも、すぐに解決されるわけではありません。

子タスク

  • fork: (executor: (forkApi: ForkApi) => T | Promise<T>) => ForkedTask<T>: 追加の作業を行うために使用できる「子タスク」を起動します。同期または非同期関数を引数として受け入れ、子タスクの最終的な状態と戻り値を確認したり、進行中にキャンセルしたりするために使用できる{result, cancel}オブジェクトを返します。

子タスクを起動し、その戻り値を収集するために待機できます。提供されたexecutor関数は、{pause, delay, signal}を含むforkApiオブジェクトを使用して非同期的に呼び出され、一時停止またはキャンセル状態の確認が可能になります。リスナーのスコープからlistenerApiも使用できます。

これの例としては、サーバーからのイベントをリッスンする無限ループを含む子タスクをフォークするリスナーがあります。親はその後、listenerApi.condition()を使用して「停止」アクションを待ち、子タスクをキャンセルします。

タスクと結果の型は…

interface ForkedTaskAPI {
pause<W>(waitFor: Promise<W>): Promise<W>
delay(timeoutMs: number): Promise<void>
signal: AbortSignal
}

export type TaskResolved<T> = {
readonly status: 'ok'
readonly value: T
}

export type TaskRejected = {
readonly status: 'rejected'
readonly error: unknown
}

export type TaskCancelled = {
readonly status: 'cancelled'
readonly error: TaskAbortError
}

export type TaskResult<Value> =
| TaskResolved<Value>
| TaskRejected
| TaskCancelled

export interface ForkedTask<T> {
result: Promise<TaskResult<T>>
cancel(): void
}

TypeScriptの使用

ミドルウェアコードは完全にTSで型付けされています。ただし、startListeningaddListener関数は、ストアのRootState型をデフォルトでは認識しないため、getState()unknownを返します。

これを修正するために、ミドルウェアは、事前型付けされたReact-Reduxフックで使用されるパターンと同様、「事前型付けされた」メソッドのバージョンを定義するための型を提供します。特に、実際のconfigureStore()呼び出しとは別のファイルでミドルウェアインスタンスを作成することをお勧めします。

// listenerMiddleware.ts
import { createListenerMiddleware, addListener } from '@reduxjs/toolkit'
import type { RootState, AppDispatch } from './store'

export const listenerMiddleware = createListenerMiddleware()

export const startAppListening = listenerMiddleware.startListening.withTypes<
RootState,
AppDispatch
>()

export const addAppListener = addListener.withTypes<RootState, AppDispatch>()

次に、コンポーネントでこれらの事前型付けされたメソッドをインポートして使用します。

使用方法ガイド

全体的な目的

このミドルウェアを使用すると、あるアクションがディスパッチされたときに追加のロジックを実行できます。これは、重いランタイムバンドルコストと大きな概念的オーバーヘッドの両方を抱えるsagaやobservableのようなミドルウェアの軽量な代替手段です。

このミドルウェアは、考えられるすべてのユースケースを処理することを意図したものではありません。thunkのように、基本的なプリミティブのセット(dispatchgetStateへのアクセスを含む)を提供し、任意の同期または非同期ロジックを記述する自由を与えます。これは強み(何でもできる!)であり、弱み(何でもできる、ガードレールなし!)でもあります。

このミドルウェアには、takeLatesttakeLeadingdebounceなどの多くのRedux-Sagaエフェクト演算子と同等のものを記述するのに十分な非同期ワークフロープリミティブがいくつか含まれていますが、それらのメソッドは直接含まれていません。(リスナーミドルウェアのテストファイルで、これらのエフェクトと同等のコードの記述方法の例を参照してください。

標準的な使用方法

最も一般的な予想される使用方法は、「特定のアクションがディスパッチされた後にいくつかのロジックを実行する」ことです。たとえば、特定のアクションを探して抽出されたデータをサーバーに送信し、ストアからユーザーの詳細を取得することで、単純な分析トラッカーを設定できます。

listenerMiddleware.startListening({
matcher: isAnyOf(action1, action2, action3),
effect: (action, listenerApi) => {
const user = selectUserDetails(listenerApi.getState())

const { specialData } = action.meta

analyticsApi.trackUsage(action.type, user, specialData)
},
})

ただし、predicateオプションを使用すると、状態値が変更された場合、または状態が特定の条件に一致する場合にもロジックをトリガーできます。

listenerMiddleware.startListening({
predicate: (action, currentState, previousState) => {
// Trigger logic whenever this field changes
return currentState.counter.value !== previousState.counter.value
},
effect,
})

listenerMiddleware.startListening({
predicate: (action, currentState, previousState) => {
// Trigger logic after every action if this condition is true
return currentState.counter.value > 3
},
effect,
})

UIが要求するリソースの種類を記述するプレーンアクションをディスパッチし、ミドルウェアが自動的にそれを取得して結果アクションをディスパッチする汎用的なAPI取得機能を実装することもできます。

listenerMiddleware.startListening({
actionCreator: resourceRequested,
effect: async (action, listenerApi) => {
const { name, args } = action.payload
listenerApi.dispatch(resourceLoading())

const res = await serverApi.fetch(`/api/${name}`, ...args)
listenerApi.dispatch(resourceLoaded(res.data))
},
})

(ただし、意味のあるデータ取得動作にはRTK Queryの使用をお勧めします。これは、リスナーでできることの例にすぎません。)

listenerApi.unsubscribeメソッドはいつでも使用でき、リスナーを将来のアクションの処理から削除します。例として、本体でunsubscribe()を無条件に呼び出すことで、ワンショットリスナーを作成できます。エフェクトコールバックは関連するアクションが最初に検出されたときに実行され、すぐに登録解除され、二度と実行されません。(ミドルウェアは実際には、take/conditionメソッドで内部的にこの手法を使用しています。)

条件付きの非同期ワークフローの記述

sagaとobservableの大きな強みの1つは、特定のディスパッチされたアクションに基づいて動作の停止と開始を含む複雑な非同期ワークフローをサポートしていることです。ただし、弱みは、どちらも多くの独自の演算子(sagaの場合はcall()fork()などのエフェクトメソッド、observableの場合はRxJS演算子)を持つ複雑なAPIを習得する必要があり、アプリケーションのバンドルサイズにかなりの量を追加することです。

リスナーミドルウェアはsagaやobservableを完全に置き換えることを意図したものではありませんが、長時間の非同期ワークフローも実装するための注意深く選択されたAPIセットを提供します。

リスナーは、listenerApiconditiontakeメソッドを使用して、アクションがディスパッチされるまで、または状態チェックが満たされるまで待機できます。conditionメソッドは、Temporal.ioのワークフローAPIのcondition関数から直接インスピレーションを得ており(@swyxによる提案に感謝します!)、takeRedux-Sagaのtakeエフェクトからインスピレーションを得ています。

シグネチャは…

type ConditionFunction<Action extends ReduxAction, State> = (
predicate: ListenerPredicate<Action, State> | (() => boolean),
timeout?: number,
) => Promise<boolean>

type TakeFunction<Action extends ReduxAction, State> = (
predicate: ListenerPredicate<Action, State> | (() => boolean),
timeout?: number,
) => Promise<[Action, State, State] | null>

await condition(somePredicate)を使用して、リスナーコールバックの実行を、いくつかの基準が満たされるまで一時停止できます。

述語は、すべての処理がリデューサによって処理された後に呼び出され、条件が解決される場合にtrueを返す必要があります。(これは事実上、ワンショットリスナー自体です。)ミリ秒単位のtimeout数値が指定されている場合、Promiseは、predicateが最初に返された場合はtrue、タイムアウトが期限切れになった場合はfalseに解決されます。これにより、if (await condition(predicate, timeout))のような比較を記述できます。

これにより、Redux-Sagaの「キャンセル可能なカウンター」の例など、より複雑な非同期ロジックを持つ長時間のワークフローを記述できるようになります。

テストスイートからのconditionの使用例

test('condition method resolves promise when there is a timeout', async () => {
let finalCount = 0
let listenerStarted = false

listenerMiddleware.startListening({
predicate: (action, currentState: CounterState) => {
return increment.match(action) && currentState.value === 0
},
effect: async (action, listenerApi) => {
listenerStarted = true
// Wait for either the counter to hit 3, or 50ms to elapse
const result = await listenerApi.condition(
(action, currentState: CounterState) => {
return currentState.value === 3
},
50,
)

// In this test, we expect the timeout to happen first
expect(result).toBe(false)
// Save the state for comparison outside the listener
const latestState = listenerApi.getState()
finalCount = latestState.value
},
})

store.dispatch(increment())
// The listener should have started right away
expect(listenerStarted).toBe(true)

store.dispatch(increment())

// If we wait 150ms, the condition timeout will expire first
await delay(150)
// Update the state one more time to confirm the listener isn't checking it
store.dispatch(increment())

// Handled the state update before the delay, but not after
expect(finalCount).toBe(2)
})

キャンセルとタスク管理

リスナーミドルウェアは、実行中のリスナーインスタンスのキャンセル、take/condition/pause/delay関数、およびAbortControllerに基づいて実装された「子タスク」をサポートしています。

listenerApi.pause/delay()関数は、現在のリスナーをスリープさせるためのキャンセル対応の方法を提供します。pause()はPromiseを受け入れ、delayはタイムアウト値を受け入れます。待機中にリスナーがキャンセルされると、TaskAbortErrorがスローされます。さらに、takeconditionの両方でもキャンセルの中断がサポートされています。

listenerApi.cancelActiveListeners()は、実行中の他の既存のインスタンスをキャンセルしますが、listenerApi.cancel()現在のインスタンスをキャンセルするために使用できます(これはフォークから役立つ可能性があり、深くネストされており、エフェクトの実行から抜け出すためにPromiseを直接スローできない場合があります)。listenerAPi.throwIfCancelled()は、エフェクトが他の作業を行っている間にキャンセルが発生した場合にワークフローから脱出するのにも役立ちます。

listenerApi.fork()は、追加の作業を行う「子タスク」を起動するために使用できます。これらは、その結果を収集するために待機できます。これの例は次のようになります。

listenerMiddleware.startListening({
actionCreator: increment,
effect: async (action, listenerApi) => {
// Spawn a child task and start it immediately
const task = listenerApi.fork(async (forkApi) => {
// Artificially wait a bit inside the child
await forkApi.delay(5)
// Complete the child by returning a value
return 42
})

const result = await task.result
// Unwrap the child result in the listener
if (result.status === 'ok') {
// Logs the `42` result value that was returned
console.log('Child succeeded: ', result.value)
}
},
})

複雑な非同期ワークフロー

提供された非同期ワークフロープリミティブ(cancelActiveListenerscancelunsubscribesubscribetakeconditionpausedelay)を使用して、Redux-Sagaライブラリに見られるより複雑な非同期ワークフロー機能の多くと同等の動作を実装できます。これには、throttledebouncetakeLatesttakeLeadingfork/joinなどのエフェクトが含まれます。テストスイートからのいくつかの例

test('debounce / takeLatest', async () => {
// Repeated calls cancel previous ones, no work performed
// until the specified delay elapses without another call
// NOTE: This is also basically identical to `takeLatest`.
// Ref: https://redux-saga.dokyumento.jp/docs/api#debouncems-pattern-saga-args
// Ref: https://redux-saga.dokyumento.jp/docs/api#takelatestpattern-saga-args

listenerMiddleware.startListening({
actionCreator: increment,
effect: async (action, listenerApi) => {
// Cancel any in-progress instances of this listener
listenerApi.cancelActiveListeners()

// Delay before starting actual work
await listenerApi.delay(15)

// do work here
},
})
}

test('takeLeading', async () => {
// Starts listener on first action, ignores others until task completes
// Ref: https://redux-saga.dokyumento.jp/docs/api#takeleadingpattern-saga-args

listenerMiddleware.startListening({
actionCreator: increment,
effect: async (action, listenerApi) => {
listenerCalls++

// Stop listening for this action
listenerApi.unsubscribe()

// Pretend we're doing expensive work

// Re-enable the listener
listenerApi.subscribe()
},
})
})

test('cancelled', async () => {
// cancelled allows checking if the current task was cancelled
// Ref: https://redux-saga.dokyumento.jp/docs/api#cancelled

let canceledAndCaught = false
let canceledCheck = false

// Example of canceling prior instances conditionally and checking cancellation
listenerMiddleware.startListening({
matcher: isAnyOf(increment, decrement, incrementByAmount),
effect: async (action, listenerApi) => {
if (increment.match(action)) {
// Have this branch wait around to be cancelled by the other
try {
await listenerApi.delay(10)
} catch (err) {
// Can check cancellation based on the exception and its reason
if (err instanceof TaskAbortError) {
canceledAndCaught = true
}
}
} else if (incrementByAmount.match(action)) {
// do a non-cancellation-aware wait
await delay(15)
if (listenerApi.signal.aborted) {
canceledCheck = true
}
} else if (decrement.match(action)) {
listenerApi.cancelActiveListeners()
}
},
})
})

より実践的な例として:このsagaベースの「ロングポーリング」ループは、繰り返しサーバーにメッセージを要求し、各応答を処理します。「ポーリング開始」アクションがディスパッチされるとオンデマンドで子ループが開始され、「ポーリング停止」アクションがディスパッチされるとループがキャンセルされます。

このアプローチは、リスナーミドルウェアを介して実装できます。

// Track how many times each message was processed by the loop
const receivedMessages = {
a: 0,
b: 0,
c: 0,
}

const eventPollingStarted = createAction('serverPolling/started')
const eventPollingStopped = createAction('serverPolling/stopped')

listenerMiddleware.startListening({
actionCreator: eventPollingStarted,
effect: async (action, listenerApi) => {
// Only allow one instance of this listener to run at a time
listenerApi.unsubscribe()

// Start a child job that will infinitely loop receiving messages
const pollingTask = listenerApi.fork(async (forkApi) => {
try {
while (true) {
// Cancellation-aware pause for a new server message
const serverEvent = await forkApi.pause(pollForEvent())
// Process the message. In this case, just count the times we've seen this message.
if (serverEvent.type in receivedMessages) {
receivedMessages[
serverEvent.type as keyof typeof receivedMessages
]++
}
}
} catch (err) {
if (err instanceof TaskAbortError) {
// could do something here to track that the task was cancelled
}
}
})

// Wait for the "stop polling" action
await listenerApi.condition(eventPollingStopped.match)
pollingTask.cancel()
},
})

コンポーネント内のリスナーの追加

リスナーは、dispatch(addListener()) を使用してランタイム時に追加できます。これは、dispatch にアクセスできる場所であればどこでもリスナーを追加できることを意味し、Reactコンポーネントも含まれます。

addListener のディスパッチは unsubscribe コールバックを返すため、これは自然とReactのuseEffectフックの動作にマッピングされます。useEffectフックではクリーンアップ関数を返すことができます。エフェクト内でリスナーを追加し、フックがクリーンアップされるときにリスナーを削除できます。

基本的なパターンは以下のようになります。

useEffect(() => {
// Could also just `return dispatch(addListener())` directly, but showing this
// as a separate variable to be clear on what's happening
const unsubscribe = dispatch(
addListener({
actionCreator: todoAdded,
effect: (action, listenerApi) => {
// do some useful logic here
},
}),
)
return unsubscribe
}, [])

このパターンは可能ですが、必ずしも推奨しません! ReactとReduxコミュニティでは、常にできる限り状態に基づいた動作を重視しようと努めてきました。ReactコンポーネントをReduxアクションディスパッチパイプラインに直接結び付けることで、保守がより困難になるコードベースにつながる可能性があります。

同時に、これはAPIの動作と潜在的なユースケースの両方において、有効なテクニックです。サガをコード分割アプリの一部として遅延ロードすることが一般的であり、多くの場合、「サガを注入する」ための複雑な追加設定作業が必要でした。対照的に、dispatch(addListener()) はReactコンポーネントのライフサイクルに自然に適合します。

そのため、このパターンを特に推奨しているわけではありませんが、ユーザーが可能性として認識できるように、ここに記述しておく価値があります。

ファイル内のリスナーの整理

出発点として、ストアと同じファイルではなく、app/listenerMiddleware.tsなどの別のファイルにリスナーミドルウェアを作成することをお勧めします。これにより、他のファイルからmiddleware.addListenerをインポートしようとした際に発生する可能性のある循環インポートの問題を回避できます。

そこから、リスナー関数と設定を整理するための3つの異なる方法を考案してきました。

まず、スライスファイルからエフェクトコールバックをミドルウェアファイルにインポートし、リスナーを追加します。

app/listenerMiddleware.ts
import { action1, listener1 } from '../features/feature1/feature1Slice'
import { action2, listener2 } from '../features/feature2/feature2Slice'

listenerMiddleware.startListening({ actionCreator: action1, effect: listener1 })
listenerMiddleware.startListening({ actionCreator: action2, effect: listener2 })

これはおそらく最も簡単なオプションであり、ストアの設定がすべてのスライスリデューサをまとめてアプリを作成する方法を反映しています。

2番目のオプションは逆で、スライスファイルがミドルウェアをインポートし、リスナーを直接追加します。

features/feature1/feature1Slice.ts
import { listenerMiddleware } from '../../app/listenerMiddleware'

const feature1Slice = createSlice(/* */)
const { action1 } = feature1Slice.actions

export default feature1Slice.reducer

listenerMiddleware.startListening({
actionCreator: action1,
effect: () => {},
})

これにより、すべてのロジックがスライス内に保持されますが、設定を単一のミドルウェアインスタンスにロックします。

3番目のオプションは、スライスに設定関数を作成しますが、リスナーファイルが起動時にそれを呼び出すようにします。

features/feature1/feature1Slice.ts
import type { AppStartListening } from '../../app/listenerMiddleware'

const feature1Slice = createSlice(/* */)
const { action1 } = feature1Slice.actions

export default feature1Slice.reducer

export const addFeature1Listeners = (startListening: AppStartListening) => {
startListening({
actionCreator: action1,
effect: () => {},
})
}
app/listenerMiddleware.ts
import { addFeature1Listeners } from '../features/feature1/feature1Slice'

addFeature1Listeners(listenerMiddleware.startListening)

アプリで最適に機能するアプローチを自由に使用してください。