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
を使用すると、リデューサは次のようになります。
- TypeScript
- JavaScript
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
})
})
import { createAction, createReducer } from '@reduxjs/toolkit'
const increment = createAction('counter/increment')
const decrement = createAction('counter/decrement')
const incrementByAmount = createAction('counter/incrementByAmount')
const initialState = { value: 0 }
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
オブジェクトをその引数として受け取るコールバックを受け入れます。そのビルダーは、このリジューサーが処理するアクションを定義するために呼び出すことができるaddCase
、addMatcher
およびaddDefaultCase
関数を提供します。
パラメータ
- initialState
State | (() => State)
: リジューサーが最初に呼び出されたときに使用する初期状態。それは、呼び出されたときに初期状態の値を返す「遅延初期化」関数でも構いません。これはリジューサーがundefined
をその状態の値として呼び出されるたびに使用され、主にlocalStorage
から初期状態を読み取るような場合に役立ちます。 - builderCallback
(builder: Builder) => void
builder.addCase(actionCreatorOrType, reducer)
への呼び出しでケースリジューサーを定義するためのビルダーオブジェクトを受け取るコールバック。
使用例
- TypeScript
- JavaScript
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) => {})
}
)
import { createAction, createReducer } from '@reduxjs/toolkit'
const increment = createAction('increment')
const decrement = createAction('decrement')
function isActionWithNumberPayload(action) {
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 実際のケースリジューサー関数。
- TypeScript
- JavaScript
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'
}
)
})
import { createAction, createReducer } from '@reduxjs/toolkit'
const initialState = {}
const resetAction = createAction('reset-tracked-loading-state')
function isPendingAction(action) {
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.type.endsWith('/rejected'),
(state, action) => {
state[action.meta.requestId] = 'rejected'
}
)
// matcher can just return boolean and the matcher can receive a generic argument
.addMatcher(
(action) => action.type.endsWith('/fulfilled'),
(state, action) => {
state[action.meta.requestId] = 'fulfilled'
}
)
})
builder.addDefaultCase
このアクションにケースリジューサーもマッチャリジューサーも実行されなかった場合に実行される「デフォルトケース」リジューサーを追加します。
パラメータ
- reducer フォールバック「デフォルトケース」リジューサー関数。
- TypeScript
- JavaScript
import { createReducer } from '@reduxjs/toolkit'
const initialState = { otherActions: 0 }
const reducer = createReducer(initialState, (builder) => {
builder
// .addCase(...)
// .addMatcher(...)
.addDefaultCase((state, action) => {
state.otherActions++
})
})
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
使用例
- TypeScript
- JavaScript
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) => {})
}
)
import { createAction, createReducer } from '@reduxjs/toolkit'
const increment = createAction('increment')
const decrement = createAction('decrement')
function isActionWithNumberPayload(action) {
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は、リジューサー関数が純粋で状態の値を不変として扱うことを要求します。これは状態の更新を予測可能かつ観察可能にするために不可欠ですが、時々そのような更新の実装を面倒にすることがあります。次の例を考えてみます
- TypeScript
- JavaScript
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),
]
})
})
import { createAction, createReducer } from '@reduxjs/toolkit'
const addTodo = createAction('todos/add')
const toggleTodo = createAction('todos/toggle')
const todosReducer = createReducer([], (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
のコードはずっとわかりにくく、それが単一のフラグのみを設定することを考慮すると特にそうです。
作業を楽にするために、createReducer
は immer を利用し、あたかも state を直接変更するかのように reducer を記述できるようにします。実際には、その reducer は、すべての変更を同等のコピー操作に変換する proxy state を受け取ります。
- TypeScript
- JavaScript
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
})
})
import { createAction, createReducer } from '@reduxjs/toolkit'
const addTodo = createAction('todos/add')
const toggleTodo = createAction('todos/toggle')
const todosReducer = createReducer([], (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
アクションが渡された場合に例外をスローします
- TypeScript
- JavaScript
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)]
})
})
import { createAction, createReducer } from '@reduxjs/toolkit'
const toggleTodo = createAction('todos/toggle')
const todosReducer = createReducer([], (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 の出力を受け取ります
- TypeScript
- JavaScript
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
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 値の個別の単純なコピーを作成し、これにより、通常どおり表示用にログ出力できます。
- TypeScript
- JavaScript
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))
},
},
})
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))
},
},
})