🔒 不変性はなぜ必要か?
「stateを直接変更してはいけない」。この制約の背後にある仕組みを解き明かします。 参照比較・Object.is()・Pure Componentの関係を深く理解しましょう。
🤔 問題提起:なぜ直接変更するとダメなのか?
Reactを使い始めた人がよく遭遇するバグのパターンがあります。 「stateを更新したはずなのに、UIが変わらない」。
// ❌ よくある間違い:stateのオブジェクトを直接変更
function TodoList() {
const [todos, setTodos] = useState([
{ id: 1, text: '買い物', done: false },
{ id: 2, text: '掃除', done: false },
]);
function toggleTodo(id) {
// ❌ 直接変更!
const todo = todos.find(t => t.id === id);
todo.done = !todo.done; // todosの中のオブジェクトを直接書き換え
setTodos(todos); // 同じ参照を渡している!
// → UIは更新されない(または不安定な動作をする)
}
return (
<ul>
{todos.map(todo => (
<li key={todo.id} onClick={() => toggleTodo(todo.id)}>
{todo.done ? '✓' : '○'} {todo.text}
</li>
))}
</ul>
);
} todo.doneを変更しているのに、なぜReactは更新を検知しないのでしょうか?
🎯 結論から言う
Reactは「参照(参照値)」を比較してstateの変更を検知します。
同じ参照なら「変更なし」と判断し、再レンダリングをスキップします。
const arr = [1, 2, 3]; // 直接変更(ミュータブル)→ 参照は同じ arr.push(4); // arr は [1, 2, 3, 4] になったが、「arr」という参照は変わっていない // 新しいオブジェクトを作る(イミュータブル)→ 参照が変わる const newArr = [...arr, 4]; // arr は [1, 2, 3] のまま // newArr は [1, 2, 3, 4] という新しい配列(新しい参照) // Reactは: // Object.is(arr, arr) → true → 「変わってない」→ スキップ // Object.is(arr, newArr) → false → 「変わった!」 → 再レンダリング
🗺️ 構造図:参照比較の仕組み
// number, string, boolean, null, undefined, symbol const a = 42; const b = 42; Object.is(a, b); // → true(値が同じ) // stateの数値更新 const [count, setCount] = useState(0); setCount(0); // 「0 === 0」なので再レンダリングしない setCount(1); // 「0 !== 1」なので再レンダリングする
// object, array, function
const obj1 = { x: 1 };
const obj2 = { x: 1 };
Object.is(obj1, obj2); // → false(別の参照!中身が同じでも)
Object.is(obj1, obj1); // → true(同じ参照)
// stateのオブジェクト更新
const [user, setUser] = useState({ name: 'Alice' });
// ❌ 直接変更 → 参照が同じ → 再レンダリングしない
user.name = 'Bob';
setUser(user); // Object.is(user, user) → true → スキップ!
// ✓ 新しいオブジェクト → 参照が変わる → 再レンダリングする
setUser({ ...user, name: 'Bob' }); // 新しいオブジェクト 📚 概念説明:Object.is()の動作
ReactはstateやPropsの比較にObject.is()を使っています。
これは===(厳密等価演算子)とほぼ同じですが、
2つの特殊ケースが異なります。
// Object.is() vs === の違い
// ケース1: NaN
NaN === NaN // → false(===の仕様)
Object.is(NaN, NaN) // → true(Object.isは正しく同値と判断)
// ケース2: +0 と -0
+0 === -0 // → true(===の仕様)
Object.is(+0, -0) // → false(Object.isは区別する)
// それ以外はほぼ同じ
Object.is(1, 1) // → true
Object.is('a', 'a') // → true
Object.is(null, null)// → true
Object.is({}, {}) // → false(別参照)
// Reactがこれを使う理由:
// NaN を state に使ったとき、setCount(NaN) で
// 「現在もNaN、次もNaN → 変化なし」と正しく判断できる どこでObject.is()が使われているか
setState(newValue)が呼ばれたとき、
Object.is(currentState, newValue)で比較。
同じ値なら再レンダリングをスキップ。
親が再レンダリングされたとき、子コンポーネントのpropsを
Object.is()で比較。
全propsが同じなら子の再レンダリングをスキップ。
依存配列の要素をObject.is()で比較。
全要素が同じなら再計算をスキップ。
依存配列の要素をObject.is()で比較。
変化があればeffectを再実行。
💻 コード例:正しいイミュータブルな更新パターン
配列の更新
const [items, setItems] = useState(['a', 'b', 'c']);
// ✓ 追加
setItems([...items, 'd']);
setItems(prev => [...prev, 'd']);
// ✓ 削除
setItems(items.filter(item => item !== 'b'));
setItems(prev => prev.filter(item => item !== 'b'));
// ✓ 更新(特定要素を変更)
setItems(items.map(item => item === 'b' ? 'B' : item));
// ✓ 挿入(インデックス指定)
setItems([...items.slice(0, 2), 'X', ...items.slice(2)]);
// ❌ NG: 直接変更
items.push('d'); // mutate!
items[0] = 'A'; // mutate!
items.splice(1, 1); // mutate! オブジェクトの更新
const [user, setUser] = useState({
name: 'Alice',
address: {
city: 'Tokyo',
zip: '100-0001'
}
});
// ✓ シャロウコピー(スプレッド演算子)
setUser({ ...user, name: 'Bob' });
// ✓ ネストしたオブジェクトの更新(各レベルでコピー)
setUser({
...user,
address: {
...user.address,
city: 'Osaka' // cityだけ変更
}
});
// ❌ NG: 直接変更(ネストが深くても同じ問題)
user.name = 'Bob'; // mutate!
user.address.city = 'Osaka'; // mutate!(参照は変わっていない)
// ✓ Immerを使うと直接変更のように書けて、内部でイミュータブルに処理
import { produce } from 'immer';
setUser(produce(draft => {
draft.address.city = 'Osaka'; // 見た目は直接変更だが安全
})); useReducerでの不変性
type Action =
| { type: 'ADD_ITEM'; payload: string }
| { type: 'REMOVE_ITEM'; payload: number }
| { type: 'TOGGLE_ITEM'; payload: number };
interface State {
items: { id: number; text: string; done: boolean }[];
}
function reducer(state: State, action: Action): State {
switch (action.type) {
case 'ADD_ITEM':
return {
...state,
// ✓ 新しい配列を返す(元のstateは変更しない)
items: [...state.items, { id: Date.now(), text: action.payload, done: false }]
};
case 'REMOVE_ITEM':
return {
...state,
items: state.items.filter(item => item.id !== action.payload)
};
case 'TOGGLE_ITEM':
return {
...state,
items: state.items.map(item =>
item.id === action.payload
? { ...item, done: !item.done } // ✓ 新しいオブジェクト
: item
)
};
default:
return state; // stateをそのまま返す(変更なし)
}
} 🔧 仕組み分解:Pure ComponentとReact.memo
不変性の恩恵を最大限に受けるのがPure ComponentとReact.memoです。 これらはpropsが変わっていなければ再レンダリングをスキップします。
React.memoの動作
// React.memoでラップすると、propsが変わらない限り再レンダリングしない
const ExpensiveList = React.memo(function ExpensiveList({ items }) {
console.log('ExpensiveList レンダリング');
return (
<ul>
{items.map(item => <li key={item.id}>{item.text}</li>)}
</ul>
);
});
function Parent() {
const [count, setCount] = useState(0);
const [items, setItems] = useState([{ id: 1, text: 'item1' }]);
return (
<div>
<button onClick={() => setCount(c => c + 1)}>Count: {count}</button>
{/* countが変わってもitemsが変わらなければExpensiveListはスキップ */}
<ExpensiveList items={items} />
</div>
);
} React.memoが効かないケース
function Parent() {
const [count, setCount] = useState(0);
// ❌ レンダリングのたびに新しい配列が作られる
const items = [{ id: 1, text: 'item1' }]; // 毎回新しい参照!
// ❌ レンダリングのたびに新しい関数が作られる
const handleClick = () => console.log('clicked'); // 毎回新しい参照!
// React.memoはObject.is()で比較するが、
// 毎回新しい参照 → 常に「変わった」と判断 → React.memoが無効化
return <MemoizedChild items={items} onClick={handleClick} />;
}
// 解決策
function Parent() {
const [count, setCount] = useState(0);
// ✓ useMemoで配列をキャッシュ
const items = useMemo(() => [{ id: 1, text: 'item1' }], []);
// ✓ useCallbackで関数をキャッシュ
const handleClick = useCallback(() => console.log('clicked'), []);
return <MemoizedChild items={items} onClick={handleClick} />;
} 不変性と参照安定性の関係
// 不変性の原則:「変更が必要なときは新しいオブジェクトを作る」
// 参照安定性の原則:「変更がないときは同じ参照を保つ」
// この2つが組み合わさって、Reactの最適化が機能する
// ① 変更があった部分 → 新しい参照 → React.memoが再レンダリングを許可
// ② 変更がなかった部分 → 同じ参照 → React.memoが再レンダリングをスキップ
// useReducerの場合も同じ
function reducer(state, action) {
if (action.type === 'IRRELEVANT') {
return state; // ← 同じ参照を返す = 「変更なし」
}
return { ...state, relevant: newValue }; // ← 新しい参照 = 「変更あり」
} 🛠️ Immerで複雑なネスト更新を簡略化
深くネストしたオブジェクトのイミュータブル更新は、 スプレッド演算子だけでは冗長になりがちです。 Immerはこの問題を解決するライブラリです。
// スプレッド演算子:ネストが深いと辛い
setConfig({
...config,
server: {
...config.server,
database: {
...config.server.database,
pool: {
...config.server.database.pool,
max: 20 // ← ここだけ変えたい
}
}
}
});
// Immerを使うと:
import { produce } from 'immer';
setConfig(produce(draft => {
draft.server.database.pool.max = 20; // まるで直接変更のように書ける
}));
// produce()の内部では:
// 1. stateのProxyを作成(draft)
// 2. draftへの変更を追跡
// 3. 変更があった部分だけ新しいオブジェクトを作成
// 4. 新しいイミュータブルなstateを返す 📌 まとめ
- ✓ Reactはstate変更の検知に Object.is() による参照比較を使う
- ✓ オブジェクト・配列を直接変更すると参照が変わらず「変更なし」と判断される
- ✓ 不変性:変更するときは必ず新しいオブジェクト・配列を作成する
- ✓ スプレッド演算子(...)、filter()、map() などがイミュータブル更新の基本パターン
- ✓ React.memo は props の参照比較で再レンダリングをスキップする最適化
- ✓ Immer は「Proxyで変更を追跡し、差分だけ新しいオブジェクトを作る」ライブラリ
- ✓ 不変性(変更時は新参照)と参照安定性(変更なし時は同参照)の両立が最適化の鍵