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

createReducer()

概要

Reduxのリデューサ関数の作成を簡略化するユーティリティ。これはImmerを内部的に使用し、リデューサ内で「ミューテーション」コードを書くことでイミュータブル更新ロジックを大幅に簡略化し、特定のアクションタイプを直接、そのアクションがディスパッチされたときに状態を更新するケースリデューサ関数にマッピングできます。

Reduxのリデューサ(reducers)は、処理するアクションタイプごとに1つのcaseがあるswitchステートメントを使用して実装されることがよくあります。

const initialState = { value: 0 }

function counterReducer(state = initialState, action) {
switch (action.type) {
case 'increment':
return { ...state, value: state.value + 1 }
case 'decrement':
return { ...state, value: state.value - 1 }
case 'incrementByAmount':
return { ...state, value: state.value + action.payload }
default:
return state
}
}

このアプローチはうまく機能しますが、少しボイラープレートが多く、エラーが発生しやすいです。たとえば、defaultケースを忘れたり、初期状態を設定したりするのは簡単です。

createReducerヘルパーは、このようなリデューサの実装を合理化します。これは「ビルダーコールバック」表記を使用して、特定のアクションタイプに対するハンドラを定義したり、一連のアクションを照合したり、デフォルトケースを処理したりします。これは概念的にはswitchステートメントに似ていますが、より優れたTSサポートがあります。

createReducerを使用すると、リデューサは次のようになります。

import { createAction, createReducer } from '@reduxjs/toolkit'

interface CounterState {
value: number
}

const increment = createAction('counter/increment')
const decrement = createAction('counter/decrement')
const incrementByAmount = createAction<number>('counter/incrementByAmount')

const initialState = { value: 0 } satisfies CounterState as CounterState

const counterReducer = createReducer(initialState, (builder) => {
builder
.addCase(increment, (state, action) => {
state.value++
})
.addCase(decrement, (state, action) => {
state.value--
})
.addCase(incrementByAmount, (state, action) => {
state.value += action.payload
})
})

「ビルダーコールバック」表記を使用した使用方法

この関数はbuilderオブジェクトをその引数として受け取るコールバックを受け入れます。そのビルダーは、このリジューサーが処理するアクションを定義するために呼び出すことができるaddCaseaddMatcherおよびaddDefaultCase関数を提供します。

パラメータ

  • initialState State | (() => State): リジューサーが最初に呼び出されたときに使用する初期状態。それは、呼び出されたときに初期状態の値を返す「遅延初期化」関数でも構いません。これはリジューサーがundefinedをその状態の値として呼び出されるたびに使用され、主にlocalStorageから初期状態を読み取るような場合に役立ちます。
  • builderCallback (builder: Builder) => void builder.addCase(actionCreatorOrType, reducer)への呼び出しでケースリジューサーを定義するためのビルダーオブジェクトを受け取るコールバック。

使用例

import {
createAction,
createReducer,
UnknownAction,
PayloadAction,
} from '@reduxjs/toolkit'

const increment = createAction<number>('increment')
const decrement = createAction<number>('decrement')

function isActionWithNumberPayload(
action: UnknownAction
): action is PayloadAction<number> {
return typeof action.payload === 'number'
}

const reducer = createReducer(
{
counter: 0,
sumOfNumberPayloads: 0,
unhandledActions: 0,
},
(builder) => {
builder
.addCase(increment, (state, action) => {
// action is inferred correctly here
state.counter += action.payload
})
// You can chain calls, or have separate `builder.addCase()` lines each time
.addCase(decrement, (state, action) => {
state.counter -= action.payload
})
// You can apply a "matcher function" to incoming actions
.addMatcher(isActionWithNumberPayload, (state, action) => {})
// and provide a default case if no other handlers matched
.addDefaultCase((state, action) => {})
}
)

ビルダーメソッド

builder.addCase

単一の正確なアクションタイプを処理するケースリジューサーを追加します。

builder.addMatcherまたはbuilder.addDefaultCaseへの呼び出しの前に、builder.addCaseへのすべての呼び出しを行う必要があります。

パラメータ

  • actionCreator プレーンアクションタイプ文字列、またはアクションタイプを決定するために使用できるcreateActionによって生成されたアクションクリエイター。
  • reducer 実際のケースリジューサー関数。

builder.addMatcher

action.typeプロパティだけでなく、独自のフィルター関数に対して着信アクションを照合できます。

複数のマッチャリジューサーが一致する場合、それらは定義された順にすべて実行されます。ケースリジューサーがすでに一致している場合でも同様です。builder.addMatcherへのすべての呼び出しは、builder.addCaseへの呼び出しの後にbuilder.addDefaultCaseへの呼び出しの前に行われる必要があります。

パラメータ

  • matcher マッチャ関数。TypeScriptでは、これはtype predicate関数である必要があります
  • reducer 実際のケースリジューサー関数。
import {
createAction,
createReducer,
AsyncThunk,
UnknownAction,
} from '@reduxjs/toolkit'

type GenericAsyncThunk = AsyncThunk<unknown, unknown, any>

type PendingAction = ReturnType<GenericAsyncThunk['pending']>
type RejectedAction = ReturnType<GenericAsyncThunk['rejected']>
type FulfilledAction = ReturnType<GenericAsyncThunk['fulfilled']>

const initialState: Record<string, string> = {}
const resetAction = createAction('reset-tracked-loading-state')

function isPendingAction(action: UnknownAction): action is PendingAction {
return typeof action.type === 'string' && action.type.endsWith('/pending')
}

const reducer = createReducer(initialState, (builder) => {
builder
.addCase(resetAction, () => initialState)
// matcher can be defined outside as a type predicate function
.addMatcher(isPendingAction, (state, action) => {
state[action.meta.requestId] = 'pending'
})
.addMatcher(
// matcher can be defined inline as a type predicate function
(action): action is RejectedAction => action.type.endsWith('/rejected'),
(state, action) => {
state[action.meta.requestId] = 'rejected'
}
)
// matcher can just return boolean and the matcher can receive a generic argument
.addMatcher<FulfilledAction>(
(action) => action.type.endsWith('/fulfilled'),
(state, action) => {
state[action.meta.requestId] = 'fulfilled'
}
)
})

builder.addDefaultCase

このアクションにケースリジューサーもマッチャリジューサーも実行されなかった場合に実行される「デフォルトケース」リジューサーを追加します。

パラメータ

  • reducer フォールバック「デフォルトケース」リジューサー関数。
import { createReducer } from '@reduxjs/toolkit'
const initialState = { otherActions: 0 }
const reducer = createReducer(initialState, (builder) => {
builder
// .addCase(...)
// .addMatcher(...)
.addDefaultCase((state, action) => {
state.otherActions++
})
})

戻り値

生成されたリジューサー関数。

リジューサーには、呼び出されると初期状態を返すgetInitialState関数が添付されます。これはReactのuseReducerフックとのテストまたは使用に役立ちます

const counterReducer = createReducer(0, (builder) => {
builder
.addCase('increment', (state, action) => state + action.payload)
.addCase('decrement', (state, action) => state - action.payload)
})

console.log(counterReducer.getInitialState()) // 0

使用例

import {
createAction,
createReducer,
UnknownAction,
PayloadAction,
} from '@reduxjs/toolkit'

const increment = createAction<number>('increment')
const decrement = createAction<number>('decrement')

function isActionWithNumberPayload(
action: UnknownAction
): action is PayloadAction<number> {
return typeof action.payload === 'number'
}

const reducer = createReducer(
{
counter: 0,
sumOfNumberPayloads: 0,
unhandledActions: 0,
},
(builder) => {
builder
.addCase(increment, (state, action) => {
// action is inferred correctly here
state.counter += action.payload
})
// You can chain calls, or have separate `builder.addCase()` lines each time
.addCase(decrement, (state, action) => {
state.counter -= action.payload
})
// You can apply a "matcher function" to incoming actions
.addMatcher(isActionWithNumberPayload, (state, action) => {})
// and provide a default case if no other handlers matched
.addDefaultCase((state, action) => {})
}
)

直接状態の変更

Reduxは、リジューサー関数が純粋で状態の値を不変として扱うことを要求します。これは状態の更新を予測可能かつ観察可能にするために不可欠ですが、時々そのような更新の実装を面倒にすることがあります。次の例を考えてみます

import { createAction, createReducer } from '@reduxjs/toolkit'

interface Todo {
text: string
completed: boolean
}

const addTodo = createAction<Todo>('todos/add')
const toggleTodo = createAction<number>('todos/toggle')

const todosReducer = createReducer([] as Todo[], (builder) => {
builder
.addCase(addTodo, (state, action) => {
const todo = action.payload
return [...state, todo]
})
.addCase(toggleTodo, (state, action) => {
const index = action.payload
const todo = state[index]
return [
...state.slice(0, index),
{ ...todo, completed: !todo.completed },
...state.slice(index + 1),
]
})
})

addTodoリジューサーは、ES6スプレッド構文を知っていれば簡単です。しかしながら、toggleTodoのコードはずっとわかりにくく、それが単一のフラグのみを設定することを考慮すると特にそうです。

作業を楽にするために、createReducerimmer を利用し、あたかも state を直接変更するかのように reducer を記述できるようにします。実際には、その reducer は、すべての変更を同等のコピー操作に変換する proxy state を受け取ります。

import { createAction, createReducer } from '@reduxjs/toolkit'

interface Todo {
text: string
completed: boolean
}

const addTodo = createAction<Todo>('todos/add')
const toggleTodo = createAction<number>('todos/toggle')

const todosReducer = createReducer([] as Todo[], (builder) => {
builder
.addCase(addTodo, (state, action) => {
// This push() operation gets translated into the same
// extended-array creation as in the previous example.
const todo = action.payload
state.push(todo)
})
.addCase(toggleTodo, (state, action) => {
// The "mutating" version of this case reducer is much
// more direct than the explicitly pure one.
const index = action.payload
const todo = state[index]
todo.completed = !todo.completed
})
})

「変更を加える」reducer を記述すると、コードが簡素化されます。より短く、間接的な記述が減り、入れ子状の state の展開時に一般的に発生する間違いがなくなります。ただし、Immer を使用すると、「魔法」が追加され、Immer は動作に独自の特徴があります。immer ドキュメントに記載されている落とし穴 を熟読する必要があります。最も重要な点は、state 引数に変更を加えるか、新しい state を返すようにする必要があるものの、その両方を行うことはできない という点です。たとえば、以下の reducer は、toggleTodo アクションが渡された場合に例外をスローします

import { createAction, createReducer } from '@reduxjs/toolkit'

interface Todo {
text: string
completed: boolean
}

const toggleTodo = createAction<number>('todos/toggle')

const todosReducer = createReducer([] as Todo[], (builder) => {
builder.addCase(toggleTodo, (state, action) => {
const index = action.payload
const todo = state[index]

// This case reducer both mutates the passed-in state...
todo.completed = !todo.completed

// ... and returns a new value. This will throw an
// exception. In this example, the easiest fix is
// to remove the `return` statement.
return [...state.slice(0, index), todo, ...state.slice(index + 1)]
})
})

複数のケース reducer の実行

当初、createReducer は、指定されたアクションタイプを常に単一のケース reducer と照合しており、そのケース reducer だけが指定されたアクションに対して実行されていました。

アクションのマッチャーを使用すると、複数のマッチャーが単一のアクションを処理するようになるため、この動作は変わります。

ディスパッチされたアクションに対して、その動作は以下のようになります。

  • アクションタイプと正確に一致するものがある場合、対応するケース reducer が最初に実行されます
  • true を返すすべてのマッチャーは、定義された順序で実行されます
  • デフォルトのケース reducer が指定されており、ケース reducer もマッチャー reducer も実行されない場合、デフォルトのケース reducer が実行されます
  • ケース reducer もマッチャー reducer も実行されない場合、もとの既存の state の値は変更されずに返されます

実行される reducer はパイプラインを形成し、それぞれの reducer は前の reducer の出力を受け取ります

import { createReducer } from '@reduxjs/toolkit'

const reducer = createReducer(0, (builder) => {
builder
.addCase('increment', (state) => state + 1)
.addMatcher(
(action) => action.type.startsWith('i'),
(state) => state * 5
)
.addMatcher(
(action) => action.type.endsWith('t'),
(state) => state + 2
)
})

console.log(reducer(0, { type: 'increment' }))
// Returns 7, as the 'increment' case and both matchers all ran in sequence:
// - case 'increment": 0 => 1
// - matcher starts with 'i': 1 => 5
// - matcher ends with 't': 5 => 7

ドラフトステート値のログ出力

開発者は、開発プロセス中に console.log(state) を呼び出すことがよくあります。ただし、ブラウザでは、プロキシが読み取りにくい形式で表示されるため、Immer ベースの state のコンソールログが困難になることがあります。

createSlice または createReducer を使用している場合、immer ライブラリ から再エクスポートされる current ユーティリティを使用できます。このユーティリティは、現在の Immer Draft state 値の個別の単純なコピーを作成し、これにより、通常どおり表示用にログ出力できます。

import { createSlice, current } from '@reduxjs/toolkit'

const slice = createSlice({
name: 'todos',
initialState: [{ id: 1, title: 'Example todo' }],
reducers: {
addTodo: (state, action) => {
console.log('before', current(state))
state.push(action.payload)
console.log('after', current(state))
},
},
})