⚛️ React Boundary
内部構造 — 設計思想

🔒 不変性はなぜ必要か?

「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 → 「変わった!」  → 再レンダリング

🗺️ 構造図:参照比較の仕組み

JavaScriptのメモリモデル:値 vs 参照
プリミティブ値(値で比較)
// 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()が使われているか

useState / useReducer

setState(newValue)が呼ばれたとき、 Object.is(currentState, newValue)で比較。 同じ値なら再レンダリングをスキップ。

React.memo

親が再レンダリングされたとき、子コンポーネントのpropsを Object.is()で比較。 全propsが同じなら子の再レンダリングをスキップ。

useMemo / useCallback

依存配列の要素をObject.is()で比較。 全要素が同じなら再計算をスキップ。

useEffect

依存配列の要素を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 ComponentReact.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で変更を追跡し、差分だけ新しいオブジェクトを作る」ライブラリ
  • ✓ 不変性(変更時は新参照)と参照安定性(変更なし時は同参照)の両立が最適化の鍵

関連記事