不変性は好みではなく性能の装置だ
一言でいうと
状態管理ライブラリが不変更新を求めるのは、純粋さが美しいからではなく、変更されたかどうかを===1つで判断するためです。
まず問題から
画面に状態を描画するアプリには、1つの問いがあります。
何が変わったので、何を描き直さなければならないのか。
答えを出す方法は3つしかありません。
- すべて描き直す: 正確ですが遅いです
- 深い比較をする: 正確ですが、状態が大きくなるほど比較のほうが高くつきます
- 変更されたものは新しいオブジェクトになるよう強制する: 比較は
prev === nextの1回で済みます
3つ目の方法が不変性です。状態を決してその場で書き換えないと約束すれば、参照比較1つで変更検知が終わります。計算量はO(1)です。
// ❌ 제자리 변경 — 참조가 그대로라 아무도 눈치채지 못한다
state.items.push(x)
// ✅ 새 배열 — 참조가 달라진다
state = { ...state, items: [...state.items, x] }
その場での変更の症状は「たまに画面が変わらない」ことです。 データは正しいのにレンダリングが走りません。原因を探すのがいちばん難しい種類のバグです。
購読は必ず解除する
const unsub = store.subscribe(render)
// ... 컴포넌트가 사라질 때
unsub()
解除しないと、消えたはずの画面が描画され続けます。 リストの開閉を繰り返すとリスナーが溜まっていき、クリック1回で何百回もレンダリングが走ります。ReactのuseEffectがクリーンアップ関数を返す設計になっている理由であり、VueがonUnmountedを用意している理由です。
購読を作るコードと解除するコードは、同じ画面の中で見えるようにします。 離れてしまうと、必ずどちらかが抜けます。
変わっていなければ通知しない
dispatch(s => s) // 같은 참조를 돌려줬다
このとき、リスナーを呼んではいけません。呼ぶと「状態は同じなのにレンダリングが走る」という無駄が生まれ、そのレンダリングが再びdispatchすると無限ループになります。
派生状態を保存しない
これは状態設計でいちばんよくある間違いです。
// ❌ 두 개의 진실
{ items: [...], count: 3 }
// ✅ 하나의 진실 + 계산
{ items: [...] }
const count = items.length
保存すると、2つがずれる瞬間が必ず来ます。 どこかでitemsだけを直してcountを直し忘れるのです。そのバグは「数字がたまに間違っている」という形で現れ、再現できません。
React公式ドキュメントが、useEffectで派生状態を同期してはいけないと明言しているのも同じ理由です。計算できるものは計算します。
では計算が高くつくときは
メモ化です。入力が同じなら、前回の結果をそのまま返します。
function memo(fn) {
let lastArg, lastOut, has = false
return arg => {
if (has && arg === lastArg) return lastOut // 참조 비교 하나
lastArg = arg; lastOut = fn(arg); has = true
return lastOut
}
}
ここで不変性が再び価値を発揮します。入力が不変なら、arg === lastArgだけで「変わっていない」とわかります。その場で書き換える状態では、この最適化は成り立ちません。
useMemo、reselect、Vueのcomputedは、すべてこの3行です。
バッチ処理: 1ティックで何度変更してもレンダリングは1回
dispatch(a); dispatch(b); dispatch(c) // 리스너는 몇 번 불려야 하나?
3回呼ぶと、画面が3回描画されます。途中の2回は、ユーザーが見ることのない状態です。そのためライブラリはマイクロタスク1つにまとめます。
let scheduled = false
function notify() {
if (scheduled) return
scheduled = true
queueMicrotask(() => { scheduled = false; listeners.forEach(l => l()) })
}
React 18のautomatic batchingがこれです。それ以前は、イベントハンドラーの外(例: setTimeoutの中)のsetStateはバッチ処理されず、レンダリングが何度も走っていました。
走査中に購読を解除すると
リスナーの中でunsub()を呼ぶことはよくあります(1回だけ聞いて切る場合)。配列をそのまま走査しながら元の配列を変更すると、次のリスナーを飛ばしてしまいます。
[...listeners].forEach(l => l()) // 복사본을 순회한다
1行ですが、これがないと「たまに1つだけ呼ばれない」バグになります。再現率が低く、原因の特定に何日もかかります。
現場では、ライブラリを選ぶとき
状態管理ライブラリを乗り換える理由は、たいてい「APIがきれいだから」ではなく、上の6つのうちどれかにつまずいたからです。ところが乗り換え先でも、派生値を状態に保存する癖や購読を解除しない癖が残っていれば、同じバグがそのままついてきます。道具を変えれば消える問題ではありません。
ライブラリを選ぶ基準はAPIの見た目ではありません。上の6つをどう処理しているかです。自分で一度作ってみると、ドキュメントの1文1文がすべて理由を持って見えてきます。