はじめに
Object.freeze()をかけ忘れたオブジェクトを、どこかのコードがpushやspliceで
書き換えてしまい、意図しない場所でstateが変わっていた——そんなバグに遭遇したことはないでしょうか。
JavaScriptの配列やオブジェクトは、参照さえ持っていれば誰でも中身を変更できてしまいます。
うっかりarray.sort()を呼んだだけで元のstateが並び替わっていた、という経験がある方も
多いと思います。
Immutable.jsは、この問題を「規約」ではなく「型」で解決するライブラリです。
ListやMapといった専用のコレクションクラスを提供し、それらには最初から
pushやspliceのような書き換えメソッドが存在しません。どんな操作も必ず新しい
インスタンスを返すため、意図しないミューテーションがそもそも起こり得ない構造になっています。
とはいえ、読むより触った方が早いと思います。ListとMapでTodoリストを操作する
サンプルを置いてあるので、先に挙動を見たい方はこちらからどうぞ。
Immutable.jsとは
Immutable.jsは、Facebook(現Meta)が開発した「Immutable persistent data collections
for Javascript」を掲げるライブラリです。List・Map・OrderedMap・Set・
OrderedSet・Stack・Record・Seqという、ES2015のArray・Map・Setに似たAPIを
持つ不変コレクションを提供します。ReduxがまだImmerを内蔵していなかった時代から
定番として使われ、現在もメンテナンスが続いている息の長いプロジェクトです。
主な特徴
- 書き換えメソッドが存在しない -
ListやMapにはpushやsetに相当する操作がありますが、いずれも元のインスタンスは変更せず新しいインスタンスを返します。ミュータブルな操作自体が用意されていないため、「うっかり書き換える」という事故が構造的に起きません - 構造共有によるパフォーマンス - ハッシュマップトライとベクタトライという木構造を内部で使い、変更があった部分の経路だけを新しいノードに差し替えます。要素数が数千件になるような大きなコレクションでも、毎回まるごとコピーする必要がありません
- 参照等価性での変更検知 - 値が変わっていなければ
set()しても同じ参照を返すため、===や専用のis()関数だけでオブジェクト全体を再帰的に比較せずに変更を検知できます - Recordによる型付きオブジェクト - デフォルト値を持つ「構造体」のようなオブジェクトを定義でき、プレーンオブジェクトよりも意図が明確なデータモデルを作れます
- Seqによる遅延評価 -
mapやfilterをチェーンしても、実際に値が必要になるまで中間コレクションを作らずに評価を遅延できます - TypeScript型定義を同梱 - パッケージ自体に型定義が含まれており、追加の
@typesパッケージなしで型の恩恵を受けられます
インストール
npm install immutable
yarn add immutable
pnpm add immutable
Immutable.jsのサンプルを動かす
ListとMapを組み合わせてTodoリストを作ってみます。todos.push(Map({...}))で
項目を追加し、todos.setIn([index, 'done'], true)で特定項目のプロパティだけを
書き換えます。どちらの操作も、呼び出す前のtodosは一切変更しません。
要点だけを抜き出すと、次のようになります。pushした後のListは元のListとは
別のオブジェクトになっており、===比較で確認できます。
import { List, Map } from 'immutable'
const todos = List([
Map({ id: 1, text: 'Immutable.jsを学ぶ', done: false }),
])
const next = todos.push(Map({ id: 2, text: '記事を書く', done: false }))
todos === next // false(元のListは変更されない)
todos.size // 1
next.size // 2
実際に動かせるものが下です。テキストを入力して「追加」を押すとList.push()で
新しいTodoが加わり、チェックボックスを押すとsetIn()で該当項目のdoneだけが
書き換わります。コンソールには、操作のたびにListが別オブジェクトになっている
ことが出力されます。
todos.push(...)やtodos.setIn(...)はどちらも新しいListを返すだけで、呼び出し前の
todos(コンソールでprevと比較しているもの)は一切変更されていません。サンプルの
初期データにある2件目「構造共有を理解する」のチェックを外したままにしても、1件目の
状態には影響しないことも確認できます。これは、変更していない要素の参照がそのまま
共有されているためです。
基本的な使い方
もっとも基本的な操作は、MapやListを作ってget・setで値を出し入れすることです。
import { Map, List } from 'immutable'
// Mapの基本操作
const map1 = Map({ a: 1, b: 2, c: 3 })
const map2 = map1.set('b', 20)
map1.get('b') // 2(元は変更されない)
map2.get('b') // 20
// Listの基本操作
const list1 = List([1, 2, 3])
const list2 = list1.push(4).set(0, 100)
list1.toArray() // [1, 2, 3]
list2.toArray() // [100, 2, 3, 4]
プレーンなJavaScriptオブジェクトとの相互変換にはfromJSとtoJSを使います。
ネストしたオブジェクトも再帰的に変換されるため、既存のstateをそのまま持ち込めます。
import { fromJS } from 'immutable'
const state = fromJS({
user: { name: 'Alice', roles: ['admin', 'editor'] },
})
state.getIn(['user', 'name']) // 'Alice'
state.toJS() // 元のプレーンオブジェクトに戻る
実践的なユースケース
ネストした構造をsetIn/updateIn/mergeDeepで更新する
深くネストしたstateの一部だけを変更したいとき、fromJSで変換した構造なら
setInにパスの配列を渡すだけでピンポイントに書き換えられます。カウンターの
ようにインクリメントしたい値にはupdateInが便利で、値がまだ存在しない場合の
初期値も指定できます。
import { fromJS } from 'immutable'
const state = fromJS({
user: { name: 'Alice', address: { city: 'Tokyo', zip: '100-0001' } },
})
const moved = state.setIn(['user', 'address', 'city'], 'Osaka')
const visited = state.updateIn(['user', 'visitCount'], 0, (n) => n + 1)
state.getIn(['user', 'address', 'city']) // 'Tokyo'(元は不変)
moved.getIn(['user', 'address', 'city']) // 'Osaka'
実際に動かせるものが下です。都市名を入力して「移動」を押すたびにsetInで
新しい状態が作られ、それまでの状態は履歴としてListに積まれていきます。
historyに積んだ過去のスナップショットは、後からstateをどれだけsetInで
書き換えても影響を受けません。すべて独立したMapインスタンスとして構造共有
されているためです。この性質はUndo機能の実装にそのまま応用でき、historyから
1つ前の要素を取り出してstateに戻すだけでUndoが実現できます。
is()による参照等価性で変更検知を高速化する
MapやListは、値が変わっていなければset()を呼んでも新しいインスタンスを
作らず同じ参照を返します。そのため、値全体を再帰的に見比べなくても===や
is()だけで「変更があったかどうか」を判定できます。ReactのshouldComponentUpdate
やメモ化の判定に使うと、深い比較コストをかけずに再レンダリングをスキップできます。
import { Map, is } from 'immutable'
function shouldUpdate(prev, next) {
return !is(prev, next) // 値としても参照としても等価ならfalse
}
const state = Map({ count: 1 })
const same = state.set('count', 1) // 値としては変化なし
state === same // true(実際には同じ参照が返る)
is(state, same) // true
実際に動かせるものが下です。「同じ値をセット」を押してもrenderCountは
増えず、「値を変える」を押したときだけis()がfalseを返して再描画とみなされます。
「同じ値をセット」を何度押してもis(state, next)はtrueのままなので
renderCountは増えません。プレーンオブジェクトの{ ...state, count: 0 }では
値が同じでも毎回新しいオブジェクトになってしまうため、この判定には使えません。
Mapが構造共有によって「値が同じなら参照も同じ」を保証しているからこそ
成立する最適化です。
Recordで型付きオブジェクトを扱う
プレーンオブジェクトの代わりに、デフォルト値を持つ「構造体」のようなオブジェクトを
定義したいときはRecordを使います。存在しないプロパティにアクセスしようとすると
警告が出るため、タイプミスにも気付きやすくなります。
import { Record } from 'immutable'
const PointRecord = Record({ x: 0, y: 0 })
const p1 = new PointRecord({ x: 10, y: 20 })
const p2 = p1.set('x', p1.x + 5)
p1.x // 10(元は変更されない)
p2.x // 15
p2 instanceof PointRecord // true
実際に動かせるものが下です。ボタンで座標を動かすたびにRecordの新しい
インスタンスが作られ、それまでの座標はListの履歴として残ります。「戻る」を
押すと履歴から直前のRecordを取り出して復元します。
move()のたびにpointは新しいPointRecordインスタンスに置き換わりますが、
historyに積まれた過去のRecordはそのまま残っているので、「戻る」ボタンで
history.pop()とhistory.last()を組み合わせるだけでUndoが実装できます。
プレーンオブジェクトでも同じことはできますが、Recordを使うとpoint instanceof PointRecordのようにインスタンスの型で判別できる分、デバッグがしやすくなります。
まとめ
Immutable.jsは、「書き換えないでください」という規約をコードレビューやコメントで
守るのではなく、ListやMapに書き換えメソッドそのものを持たせないことで解決する
ライブラリです。構造共有によって大きなコレクションでも効率的に扱え、is()による
参照等価性の判定はReactの再レンダリング最適化とも相性が良い性質です。ネストした
更新にはsetIn/updateIn/mergeDeep、型付きの構造体にはRecordと、用途に応じた
道具がひととおり揃っています。
Immerのように「ミュータブルな見た目で書く」アプローチとは対照的に、Immutable.js
は「そもそもミュータブルな操作を存在させない」という設計です。stateの意図しない
書き換えに悩まされているなら、既存のプレーンオブジェクトの一部だけでもfromJS
で置き換えて試してみてください。