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

Immerを使ったリデューサーの記述

Redux Toolkit の createReducercreateSlice は内部で自動的に Immer を使用しており、「ミューテート」構文を使って、よりシンプルな不変の更新ロジックを記述できます。これは、ほとんどのリデューサーの実装を簡素化するのに役立ちます。

Immer はそれ自体が抽象化レイヤーであるため、Redux Toolkit が Immer を使用する理由と、その正しい使用方法を理解することが重要です。

不変性とRedux

不変性の基本

「可変」とは「変更可能」という意味です。「不変」なものは、決して変更できません。

JavaScript のオブジェクトと配列は、デフォルトではすべて可変です。オブジェクトを作成した場合、そのフィールドの内容を変更できます。配列を作成した場合も、内容を変更できます。

const obj = { a: 1, b: 2 }
// still the same object outside, but the contents have changed
obj.b = 3

const arr = ['a', 'b']
// In the same way, we can change the contents of this array
arr.push('c')
arr[1] = 'd'

これはオブジェクトまたは配列をミューテートすると呼ばれます。メモリ内では同じオブジェクトまたは配列への参照ですが、オブジェクト内部の内容は変更されました。

値を不変的に更新するには、既存のオブジェクト/配列のコピーを作成し、そのコピーを変更する必要があります。.

これは、JavaScript の配列/オブジェクトの展開演算子と、元の配列をミューテートするのではなく、配列の新しいコピーを返す配列メソッドを使用して手動で行うことができます。

const obj = {
a: {
// To safely update obj.a.c, we have to copy each piece
c: 3,
},
b: 2,
}

const obj2 = {
// copy obj
...obj,
// overwrite a
a: {
// copy obj.a
...obj.a,
// overwrite c
c: 42,
},
}

const arr = ['a', 'b']
// Create a new copy of arr, with "c" appended to the end
const arr2 = arr.concat('c')

// or, we can make a copy of the original array:
const arr3 = arr.slice()
// and mutate the copy:
arr3.push('c')
もっと詳しく知りたいですか?

JavaScript での不変性の仕組みについて詳しくは、以下をご覧ください。

リデューサーと不変の更新

Redux の主なルールの1つは、リデューサーが元の/現在の状態値を決して変更してはならないということです。

危険
// ❌ Illegal - by default, this will mutate the state!
state.value = 123

Redux で状態をミューテートしてはならない理由はいくつかあります。

  • UI が最新の値を適切に表示しないなど、バグが発生する原因となります。
  • 状態がなぜ、どのように更新されたかを理解するのが難しくなります。
  • テストを書くのが難しくなります。
  • 「タイムトラベルデバッグ」を正しく使用する能力を損ないます。
  • Reduxの意図された精神と使用パターンに反します。

では、元の状態を変更できない場合、どのように更新された状態を返すのでしょうか?

ヒント

リデューサーは、元の値のコピーのみを作成でき、そのコピーをミューテートできます。

// ✅ This is safe, because we made a copy
return {
...state,
value: 123,
}

すでに、JavaScriptの配列/オブジェクトの展開演算子や、元の値のコピーを返すその他の関数を使用して、手動で不変の更新を記述できることを確認しました。

データがネストされている場合、これは難しくなります。不変の更新の重要なルールは、更新する必要のあるネストのすべてのレベルのコピーを作成する必要があるということです。

これの典型的な例は次のようになります。

function handwrittenReducer(state, action) {
return {
...state,
first: {
...state.first,
second: {
...state.first.second,
[action.someId]: {
...state.first.second[action.someId],
fourth: action.someValue,
},
},
},
}
}

ただし、「手動で不変の更新をこのように記述するのは、覚えて正しく行うのが難しい」と思っているなら... そうです、その通りです! :)

手動で不変の更新ロジックを記述するのは難しいことであり、リデューサーで状態を誤ってミューテートすることは、Reduxユーザーが犯す最も一般的な単一の間違いです。

Immerを使用した不変の更新

Immer は、不変の更新ロジックを記述するプロセスを簡素化するライブラリです。

Immer は produce と呼ばれる関数を提供します。この関数は、元の state とコールバック関数の2つの引数を受け取ります。コールバック関数には、状態の「ドラフト」バージョンが渡されます。コールバック内では、ドラフト値をミューテートするコードを安全に記述できます。Immer は、ドラフト値をミューテートしようとするすべての試行を追跡し、それらのミューテーションを不変の同等のものを使用して再生し、安全で不変的に更新された結果を作成します。

import produce from 'immer'

const baseState = [
{
todo: 'Learn typescript',
done: true,
},
{
todo: 'Try immer',
done: false,
},
]

const nextState = produce(baseState, (draftState) => {
// "mutate" the draft array
draftState.push({ todo: 'Tweet about it' })
// "mutate" the nested state
draftState[1].done = true
})

console.log(baseState === nextState)
// false - the array was copied
console.log(baseState[0] === nextState[0])
// true - the first item was unchanged, so same reference
console.log(baseState[1] === nextState[1])
// false - the second item was copied and updated

Redux ToolkitとImmer

Redux Toolkit の createReducer API は、内部で Immer を自動的に使用します。そのため、createReducer に渡されるケースリデューサー関数内で状態を「ミューテート」しても安全です。

const todosReducer = createReducer([], (builder) => {
builder.addCase('todos/todoAdded', (state, action) => {
// "mutate" the array by calling push()
state.push(action.payload)
})
})

さらに、createSlice は内部で createReducer を使用するため、そこでも状態を「ミューテート」しても安全です。

const todosSlice = createSlice({
name: 'todos',
initialState: [],
reducers: {
todoAdded(state, action) {
state.push(action.payload)
},
},
})

これは、ケースリデューサー関数が createSlice/createReducer の呼び出しの外で定義されている場合にも適用されます。たとえば、状態を「ミューテート」することを期待する再利用可能なケースリデューサー関数を用意し、必要に応じて含めることができます。

const addItemToArray = (state, action) => {
state.push(action.payload)
}

const todosSlice = createSlice({
name: 'todos',
initialState: [],
reducers: {
todoAdded: addItemToArray,
},
})

これは、実行時に「ミューテート」ロジックが内部で Immer の produce メソッドでラップされるためです。

注意

「ミューテート」ロジックは、Immer 内部にラップされている場合にのみ正しく機能することを忘れないでください! そうしないと、そのコードは実際にデータをミューテートします。

Immer の使用パターン

Redux Toolkit で Immer を使用する場合は、知っておくべき有用なパターンと注意すべき落とし穴がいくつかあります。

状態のミューテートと返却

Immer は、ネストされたフィールドへの割り当てや値をミューテートする関数の呼び出しなど、既存のドラフト状態値をミューテートしようとする試行を追跡することで機能します。つまり、Immer が試行された変更を確認するためには、state が JS オブジェクトまたは配列である必要があります。(スライスの状態が文字列やブール値のようなプリミティブであっても構いませんが、プリミティブは決してミューテートされないため、新しい値を返すしかできません。)

特定のケースリデューサーでは、Immer は、既存の状態をミューテートするか、新しい状態値を自分で構築して返すかのいずれかを行うことを期待します。同じ関数内で両方を行わないでください!たとえば、次の両方は Immer で有効なリデューサーです。

const todosSlice = createSlice({
name: 'todos',
initialState: [],
reducers: {
todoAdded(state, action) {
// "Mutate" the existing state, no return value needed
state.push(action.payload)
},
todoDeleted(state, action.payload) {
// Construct a new result array immutably and return it
return state.filter(todo => todo.id !== action.payload)
}
}
})

ただし、不変の更新を使用して作業の一部を行い、次に「ミューテーション」を介して結果を保存することは可能です。この例としては、ネストされた配列のフィルタリングが考えられます。

const todosSlice = createSlice({
name: 'todos',
initialState: {todos: [], status: 'idle'}
reducers: {
todoDeleted(state, action.payload) {
// Construct a new array immutably
const newTodos = state.todos.filter(todo => todo.id !== action.payload)
// "Mutate" the existing state to save the new array
state.todos = newTodos
}
}
})

暗黙の戻り値を持つアロー関数で状態をミューテートすると、このルールが破られ、エラーが発生することに注意してください! これは、ステートメントと関数呼び出しが値を返す場合があり、Immer は試行されたミューテーションとおよび新しく返された値の両方を確認し、どちらを結果として使用するかを認識しないためです。考えられる解決策としては、void キーワードを使用して戻り値をスキップするか、中括弧を使用してアロー関数に本体を与え、戻り値を与えないようにすることが挙げられます。

const todosSlice = createSlice({
name: 'todos',
initialState: [],
reducers: {
// ❌ ERROR: mutates state, but also returns new array size!
brokenReducer: (state, action) => state.push(action.payload),
// ✅ SAFE: the `void` keyword prevents a return value
fixedReducer1: (state, action) => void state.push(action.payload),
// ✅ SAFE: curly braces make this a function body and no return
fixedReducer2: (state, action) => {
state.push(action.payload)
},
},
})

ネストされた不変の更新ロジックを記述するのは難しいことですが、個々のフィールドを割り当てるのではなく、オブジェクトの展開演算を実行して複数のフィールドを一度に更新する方が簡単な場合もあります。

function objectCaseReducer1(state, action) {
const { a, b, c, d } = action.payload
return {
...state,
a,
b,
c,
d,
}
}

function objectCaseReducer2(state, action) {
const { a, b, c, d } = action.payload
// This works, but we keep having to repeat `state.x =`
state.a = a
state.b = b
state.c = c
state.d = d
}

別の方法として、Object.assign を使用して一度に複数のフィールドをミューテートできます。Object.assign は常に最初に指定されたオブジェクトをミューテートするためです。

function objectCaseReducer3(state, action) {
const { a, b, c, d } = action.payload
Object.assign(state, { a, b, c, d })
}

状態のリセットと置換

新しいデータをロードしたか、状態を初期値に戻す必要があるため、既存の state 全体を置き換えたい場合があります。

危険

よくある間違いは、state = someValue を直接割り当てようとすることです。これは機能しません! これは、ローカルの state 変数を別の参照にポイントするだけです。これは、メモリ内の既存の state オブジェクト/配列をミューテートするのではなく、完全に新しい値を返すものでもないため、Immer は実際には変更を加えません。

代わりに、既存の状態を置き換えるには、新しい値を直接返す必要があります。

const initialState = []
const todosSlice = createSlice({
name: 'todos',
initialState,
reducers: {
brokenTodosLoadedReducer(state, action) {
// ❌ ERROR: does not actually mutate or return anything new!
state = action.payload
},
fixedTodosLoadedReducer(state, action) {
// ✅ CORRECT: returns a new value to replace the old one
return action.payload
},
correctResetTodosReducer(state, action) {
// ✅ CORRECT: returns a new value to replace the old one
return initialState
},
},
})

ドラフトされた状態のデバッグと検査

console.log(state) のように、更新中のリデューサーから進行中の状態をログに記録して、どのように見えるかを確認したいのはよくあることです。残念ながら、ブラウザは、ログに記録されたプロキシインスタンスを読みにくい、または理解しにくい形式で表示します。

Logged proxy draft

これを回避するために、Immer には、ラップされたデータのコピーを抽出する current 関数が含まれています。RTK は current を再エクスポートします。作業中の状態をログに記録または検査する必要がある場合は、リデューサーでこれを使用できます。

import { current } from '@reduxjs/toolkit'

const todosSlice = createSlice({
name: 'todos',
initialState: todosAdapter.getInitialState(),
reducers: {
todoToggled(state, action) {
// ❌ ERROR: logs the Proxy-wrapped data
console.log(state)
// ✅ CORRECT: logs a plain JS copy of the current data
console.log(current(state))
},
},
})

正しい出力は代わりにこのようになります。

Logged current value

Immer は、original および isDraft 関数も提供します。これらは、更新を適用せずに元のデータを取得したり、特定の値がプロキシでラップされたドラフトかどうかを確認したりします。RTK 1.5.1 以降、それらの両方が RTK からも再エクスポートされています。

ネストされたデータの更新

Immer は、ネストされたデータの更新を大幅に簡素化します。ネストされたオブジェクトと配列もプロキシでラップされてドラフトされ、ネストされた値を独自の変数に抽出してからミューテートしても安全です。

ただし、これはオブジェクトと配列にのみ適用されます。プリミティブ値を独自の変数に抽出して更新しようとすると、Immer はラップするものがなく、更新を追跡できません。

const todosSlice = createSlice({
name: 'todos',
initialState: [],
reducers: {
brokenTodoToggled(state, action) {
const todo = state.find((todo) => todo.id === action.payload)
if (todo) {
// ❌ ERROR: Immer can't track updates to a primitive value!
let { completed } = todo
completed = !completed
}
},
fixedTodoToggled(state, action) {
const todo = state.find((todo) => todo.id === action.payload)
if (todo) {
// ✅ CORRECT: This object is still wrapped in a Proxy, so we can "mutate" it
todo.completed = !todo.completed
}
},
},
})

ここに注意点があります。 Immerは、ステートに新しく挿入されたオブジェクトをラップしません。ほとんどの場合、これは問題になりませんが、値を挿入した後、さらに更新したい場合があるかもしれません。

これに関連して、RTKのcreateEntityAdapterの更新関数は、スタンドアロンのリデューサーとして使用することも、「変更する」更新関数として使用することもできます。これらの関数は、与えられたステートがドラフトでラップされているかどうかを確認することによって、「変更」するか新しい値を返すかを判断します。これらの関数をケースリデューサー内で自分で呼び出す場合は、ドラフト値とプレーン値のどちらを渡しているかを必ず把握してください。

最後に、Immerはネストされたオブジェクトや配列を自動的に作成しないことに注意してください。自分で作成する必要があります。例として、ネストされた配列を含むルックアップテーブルがあり、それらの配列の1つにアイテムを挿入したいとします。配列の存在を確認せずに無条件に挿入しようとすると、配列が存在しない場合にロジックがクラッシュします。代わりに、まず配列が存在することを確認する必要があります。

const itemsSlice = createSlice({
name: 'items',
initialState: { a: [], b: [] },
reducers: {
brokenNestedItemAdded(state, action) {
const { id, item } = action.payload
// ❌ ERROR: will crash if no array exists for `id`!
state[id].push(item)
},
fixedNestedItemAdded(state, action) {
const { id, item } = action.payload
// ✅ CORRECT: ensures the nested array always exists first
if (!state[id]) {
state[id] = []
}

state[id].push(item)
},
},
})

ステートの変更をリントする

多くのESLint設定には、https://eslint.org/docs/rules/no-param-reassignルールが含まれており、ネストされたフィールドへの変更についても警告する可能性があります。これにより、Immerを利用したリデューサー内のstateへの変更についてルールが警告することがありますが、これは役に立ちません。

これを解決するには、スライスファイルでのみ、stateという名前のパラメーターへの変更と代入を無視するようにESLintルールに指示できます。

// @filename .eslintrc.js
module.exports = {
// add to your ESLint config definition
overrides: [
{
// feel free to replace with your preferred file pattern - eg. 'src/**/*Slice.ts'
files: ['src/**/*.slice.ts'],
// avoid state param assignment
rules: { 'no-param-reassign': ['error', { props: false }] },
},
],
}

なぜImmerが組み込まれているのか

私たちは、ImmerをRTKのcreateSliceおよびcreateReducer APIの必須部分ではなく、オプションの部分にしたいという要望を長年にわたって多数受け取ってきました。

私たちの答えは常に同じです。ImmerはRTKで必須であり、それは変わりません

ImmerがRTKの重要な部分であると私たちが考えている理由、そしてそれをオプションにしない理由について見ていきましょう。

Immerの利点

Immerには2つの主な利点があります。第一に、Immerは不変更新ロジックを大幅に簡素化します適切な不変更新は非常に冗長です。これらの冗長な操作は全体的に読みにくく、更新ステートメントの実際の意図も不明瞭にします。Immerは、ネストされたスプレッドと配列スライスをすべて排除します。コードが短く読みやすくなるだけでなく、実際の更新がどうなるべきかがはるかに明確になります。

第二に、不変更新を正しく書くのは難しいであり、間違いを犯しやすいのです(オブジェクトのスプレッドのセットでネストのレベルをコピーし忘れたり、トップレベルの配列をコピーして配列内で更新するアイテムをコピーしなかったり、array.sort()が配列を変更することを忘れたりするなど)。これが、偶発的な変更が常にReduxバグの最も一般的な原因であった理由の一部です。Immerは、偶発的な変更を事実上排除します。誤って書き込まれる可能性のあるスプレッド操作がなくなるだけでなく、Immerはステートも自動的にフリーズします。これにより、リデューサーの外でも誤って変更した場合にエラーがスローされます。Reduxバグの#1の原因を排除することは、非常に大きな改善です。

さらに、RTK Queryは、Immerのパッチ機能を利用して、楽観的な更新と手動キャッシュ更新も可能にします。

トレードオフと懸念事項

他のツールと同様に、Immerを使用することにはトレードオフがあり、ユーザーはそれを使用することについていくつかの懸念を表明しています。

Immerはアプリバンドル全体のサイズを増やします。およそ8K min、3.3K min+gzです(参照:Immerドキュメント:インストールBundle.js.org分析)。ただし、そのライブラリバンドルサイズは、アプリ内のリデューサーロジックの量を減らすことによって、その価値を発揮し始めます。さらに、より読みやすいコードと変更バグの排除という利点は、サイズに見合う価値があります。

Immerは実行時のパフォーマンスにも少しオーバーヘッドを追加します。ただし、Immerの「パフォーマンス」ドキュメントページによると、オーバーヘッドは実際には意味のあるものではありません。さらに、リデューサーは、Reduxアプリではパフォーマンスのボトルネックになることはほとんどありません。代わりに、UIの更新コストの方がはるかに重要です。

したがって、Immerを使用することは「無料」ではありませんが、バンドルとパフォーマンスのコストは、それだけの価値があるほど小さいものです。

Immerの使用に関する最も現実的な問題点は、ブラウザのデバッガーがプロキシを紛らわしい方法で表示するため、デバッグ中にステート変数を検査するのが難しいことです。これは確かに迷惑です。ただし、これは実行時の動作には実際には影響せず、このページの上で、currentを使用してデータの表示可能なプレーンJSバージョンを作成する方法を文書化しました。(MobxやVue 3のようなライブラリの一部としてプロキシの使用がますます広まっていることを考えると、これもImmerに固有のものではありません。)

もう1つの問題は、教育と理解です。Reduxは常にリデューサーで不変性を必要としてきたため、「変更する」コードを見ると混乱する可能性があります。Reduxの新規ユーザーが、サンプルコードでこれらの「変更」を見て、Reduxの使用法としては普通だと考え、後でcreateSliceの外で同じことを試みる可能性も十分にあります。これは、Immerが更新をラップする能力の外にあるため、実際には実際の変更とバグが発生します。

私たちは、ドキュメント全体を通して、不変性の重要性を繰り返し強調することで、これに対処しました。 「変更」が機能するのは、内部のImmerの「魔法」のおかげであることを強調する複数のハイライトされたセクションを含め、今読んでいるこの特定のドキュメントページを追加しました。

アーキテクチャと意図

Immerがオプションではない理由はあと2つあります。

1つはRTKのアーキテクチャです。createSlicecreateReducerは、Immerを直接インポートすることで実装されています。それらのいずれかの、仮説的なimmer: falseオプションを持つバージョンを作成する簡単な方法はありません。オプションのインポートはできません。また、アプリの初期ロード中にImmerを即座に同期的に利用できるようにする必要があります。

そして最後に:Immerは、ユーザーにとって最良の選択肢であると信じているため、デフォルトでRTKに組み込まれています!私たちは、ユーザーにImmerを使用してもらいたいと思っており、それをRTKの重要な不可欠なコンポーネントであると考えています。よりシンプルなリデューサーコードや偶発的な変更の防止のような大きなメリットは、比較的小さな懸念をはるかに上回ります。

詳細情報

ImmerのAPI、エッジケース、および動作の詳細については、Immerのドキュメントを参照してください。

Immerが必須である理由に関する過去の議論については、次の問題を参照してください