はじめに
ReactやReduxでstateを更新するとき、ネストしたオブジェクトの一部だけを変更したいのに、
スプレッド構文 ... を何段にも重ねて書いた経験はありませんか。
setState({
...state,
user: {
...state.user,
address: {
...state.user.address,
city: "Tokyo",
},
},
});
たった1つのプロパティを変えたいだけなのに、コードは読みにくく、
どこかで ... を書き忘れると簡単にバグります。Immerはこの問題を、
「まるでミュータブルに書いているのに、実際は新しいイミュータブルなstateが生成される」
という仕組みで解決してくれるライブラリです。
とはいえ、読むより触った方が早いと思います。Immerのproduce関数でTodoリストのstateを
書き換えるサンプルを置いてあるので、先に挙動を見たい方はこちらからどうぞ。
Immerとは
Immer(ドイツ語で「常に」の意)は、不変性(イミュータブル)を保ったまま
現在のstateを直接書き換えるような感覚でコーディングできるライブラリです。
内部ではJavaScriptの Proxy を使い、変更操作をすべて記録した上で、
最終的に元のオブジェクトを一切壊さない新しいオブジェクトを生成します。
Redux Toolkitの内部でも標準採用されており、React・Vue・Vanilla JSを問わず利用できます。
主な特徴
- 直感的な書き方 -
state.foo = "bar"のようにミュータブルな構文でstateを更新できる - 深いネストにも強い - スプレッド構文を重ねる必要がなく、ネストが深くても1行で済む
- 参照の共有によるパフォーマンス - 変更されていない部分は元のオブジェクトの参照をそのまま再利用する
- Structural Sharing - 変更前後で差分だけが新しいオブジェクトになるため、Reactの再レンダリング最適化とも相性が良い
インストール
npm install immer
yarn add immer
pnpm add immer
Immerの基本的な使い方とサンプルを動かす
中心となるのは produce 関数です。第1引数に元のstate、第2引数に
「draft(下書き)」を直接書き換えるコールバックを渡すと、新しいstateが返ってきます。
Todoリストのstateを例に、produceだけでどこまでできるかを見てみましょう。
要点だけを抜き出すと、次のようになります。draftに対してdone = trueのような代入や
pushを行うだけで、Immerが差分を検出し、新しいイミュータブルなstateを組み立ててくれます。
import { produce } from 'immer'
const baseState = {
todos: [
{ id: 1, text: 'Immerを学ぶ', done: false },
],
}
const nextState = produce(baseState, (draft) => {
draft.todos[0].done = true
draft.todos.push({ id: 2, text: '公開する', done: false })
})
// baseStateは書き換わらず、nextStateだけが新しいオブジェクトになる
console.log(baseState.todos.length) // 1
console.log(nextState.todos.length) // 2
実際に動かせるものが下です。produceに渡したコールバックの中でdraft.todosを
書き換えると、コンソールと画面のTodoリストの両方に反映されます。
draft.todos[0].done = true のように直接代入しているにもかかわらず、
baseState は一切変更されず、nextState という新しいオブジェクトが作られている点がポイントです。
上のサンプルでは、draft.todos.push() に渡すオブジェクトの text を書き換えれば追加される
Todoの文言が変わり、draft.todos[0].done を false のままにすればチェックマークが付かない
状態のまま表示されることを確認できます。produce に渡すコールバックの中身を増減させるだけで、
生成されるstateの内容をそのままコントロールできるのがポイントです。
実践的なユースケース
ImmerとReactのuseStateを組み合わせる
useState でネストしたオブジェクトを管理する場合、更新関数の中で produce を使うと、
スプレッド構文を書かずに済みます。フォームの一部フィールドだけを更新するようなケースで特に効果を発揮します。
要点はこの部分です。produce にレシピ関数だけを渡す「カリー化」の書き方をすると、
useState のセッター関数としてそのまま渡せます。
import { useState } from 'react'
import { produce } from 'immer'
function ProfileForm() {
const [profile, setProfile] = useState({
name: '田中太郎',
address: { city: '東京', zip: '100-0001' },
})
const updateCity = (city) => {
// produce(recipe) だけを渡すとsetStateのセッター関数として使える
setProfile(produce((draft) => {
draft.address.city = city
}))
}
return <button onClick={() => updateCity('大阪')}>住所を大阪に変更</button>
}
実際に動かせるものが下です。ボタンを押すと updateCity 経由で setProfile が呼ばれ、
Reactの再レンダリングと合わせて住所が切り替わります。
setProfile(produce((draft) => { ... })) のように、produce へ渡すレシピ関数だけを
setState のセッター関数として渡す「カリー化」の書き方も可能です。上のサンプルでは
updateCity に渡す文字列を書き換えたり、draft.address.zip への代入を追加すれば、
ボタン1つで郵便番号も同時に更新できるようになります。
Immerで配列操作をシンプルにする
配列内の特定要素の削除・並び替え・条件付き更新は、通常だと filter や map を
組み合わせる必要があります。Immerなら find で対象を取得したあと、splice や push と
いった配列のミュータブルメソッドをdraftに対してそのまま使えるため、直感的に書けます。
要点はこの部分です。draft.items.find() で見つけた要素を直接書き換えたり、
draft.items.splice() で配列から取り除いたりできます。
import { produce } from 'immer'
const cart = {
items: [
{ id: 1, name: 'キーボード', qty: 1 },
{ id: 2, name: 'マウス', qty: 2 },
],
}
const updatedCart = produce(cart, (draft) => {
const target = draft.items.find((item) => item.id === 2)
target.qty += 1 // 見つけた要素を直接書き換える
const removeIndex = draft.items.findIndex((item) => item.id === 1)
draft.items.splice(removeIndex, 1) // 破壊的メソッドでもimmutableな結果になる
})
実際に動かせるものが下です。produce の中で find → 直接代入、findIndex → splice
という2種類の操作を組み合わせています。
find や splice は本来配列を破壊的に変更するメソッドですが、
draft上で使う分にはimmutableな結果として反映されるため、安心して使えます。
サンプルの draft.items.push({ id: 4, name: '新商品', qty: 1 }) を追記すれば
新しい商品を1行足すだけでカートに反映されますし、removeIndex の対象idを変えれば
削除される商品を切り替えられます。
Immerでreducerパターンを実装する
Redux風の switch 文によるreducerも、produce で書くと各caseがそのまま
「stateをどう書き換えるか」を表現するだけのシンプルなコードになります。
要点はこの部分です。produce にレシピ関数だけを渡すと、(state, action) => newState
という、reducerとしてそのまま使える関数が返ってきます。
import { produce } from 'immer'
const reducer = produce((draft, action) => {
switch (action.type) {
case 'increment':
draft.count += 1
break
case 'reset':
draft.count = 0
break
}
})
let state = { count: 0 }
state = reducer(state, { type: 'increment' }) // { count: 1 }
実際に動かせるものが下です。reducer(state, action) を3回呼び出し、
action.type に応じたcaseの代入結果が積み重なって最終的なカウントに反映されます。
produce にレシピ関数だけを渡すと、(state, action) => newState という
reducerとしてそのまま使える関数が返ってきます。Redux Toolkitの createSlice が
内部で行っているのもこの仕組みです。サンプルの switch に case 'set': を追加して
draft.count = action.payload のような代入を足せば、reducer にアクション種別を
1つ増やせますし、reducer(state, { type: 'increment' }) の呼び出し回数を変えれば
最終的なカウントの値も変わります。
まとめ
Immerを使うことで、ネストしたstateの更新はスプレッド構文の羅列から解放され、 「draftを直接書き換える」という直感的なコードに変わります。それでいて実際には 不変性が保たれており、変更されていない部分は参照が共有されるためパフォーマンス面でも有利です。 React・Redux・Vueなど、どのstate管理と組み合わせても効果を発揮するので、 ネストしたstateの更新でコードが読みにくくなっていると感じたら、ぜひ導入を検討してみてください。