Tech Learning Daily

2026-09-22 (Tue) — 第 67 号
AI が毎朝届ける、ソフトウェア技術の基礎解説
Frontend 🌿 基礎 ⏱ 約 7 分

setState しただけなのに、なぜ React は新しいツリーをまるごと作り直すのか?

あなたも昨日 setCount(count + 1) のようなコードを書き、画面が自動で書き換わる様子を眺めたはずだ。あのとき React が最初にやっているのは、画面の更新とは一見関係のない「メモリ上に新しいツリーをもう一つ丸ごと作る」という遠回りな作業だ。なぜ差分だけを直接書けばいい実 DOM を、わざわざ作り直してから比べるのか説明できるだろうか。

🎯 3 行まとめ
  • React は state が変わるたびに新しい仮想 DOM ツリー(ただの JS オブジェクト)を丸ごと生成し、前回のツリーと比較(diffing)してから、変わった部分だけを実 DOM に適用する。
  • React は要素の同一性を「ツリー内の位置」で判断する。異なる型が同じ位置に来るとツリーごと再構築されて state が失われるため、リストの並び替えでは key で明示的な同一性を与える必要がある。
  • React 16 の Fiber は差分計算そのものを中断・再開可能な単位に分割し、React 18 の automatic batching は複数回の state 更新を 1 回の再レンダリングにまとめて無駄な diff 計算を減らす。

なぜ実 DOM を直接書き換えないのか

jQuery 時代のコードを思い出すと、要素を探して直接書き換えるという操作は素朴だが、変更のたびにブラウザがレイアウト・ペイントの再計算をどこまで引き起こすか、書いた本人が把握し続けなければならなかった。React は「UI は state を受け取って返す関数である」という宣言的なモデルを取り、更新のたびにコンポーネント関数をもう一度呼び出して UI 全体を再計算する体裁にした。

ただし、この呼び出し結果は最初から実 DOM ではない。仮想 DOM と呼ばれる、タグ名・属性・子要素を表すだけの軽量な JS オブジェクトのツリーである。オブジェクトを作り直すだけならレイアウト計算は走らないため、コストは低い。実際に高くつく実 DOM の書き換えは、この後の比較で「本当に変わった箇所」だけに絞り込んでから行われる。

差分計算(diffing)はどうやって軽くしているのか

2 つの任意の木構造を厳密に比較する一般的なアルゴリズムは、要素数 n に対して O(n³) の計算量がかかる。React はこれをそのまま使わず、2 つの前提を置くヒューリスティックで reconciliation(前回ツリーとの比較・差分適用)を O(n) に落としている。前提は「異なる型の要素は異なるツリーを生成する」ことと、「開発者は key でどの子要素が前回と同じかをヒントできる」ことだ。

state 更新
   │
   ▼
① 新しい仮想DOMツリーを生成(全体)
   │
   ▼
② 前回のツリーと diff(reconciliation)
   ├─ 同じ型・同じ位置 → 属性だけ更新
   └─ 型 or key が違う → ツリーごと再構築
   │
   ▼
③ 差分だけを実DOMへ commit(描画)

同じ位置に同じ型の要素が来ていれば、React は属性の変化だけを比較して子要素へ再帰する。一方 <a><img> に変わるような型の変化が起きると、React は古いツリーをその場で調べるのをやめ、サブツリーごと破棄して新しく組み立て直す。破棄されたツリーに紐づいていた state は失われる。

🍱 たとえるなら

部屋の模様替えを、毎回「家具を全部外に運び出してから新しい配置で並べ直す」のではなく、まず紙の上に新しい配置図(仮想 DOM)を描き、前回の配置図と見比べて「動いた家具」だけをリストアップしてから、そのリストの分だけ実際に体を動かして家具(実 DOM)を運ぶやり方に近い。配置図を描き直す作業は紙の上なので何度でも安いが、実際に家具を運ぶ作業だけは体力を使う。だからこそ、後者を最小限に絞り込む仕組みが要る。

位置による同一性と key の役割

型さえ揃っていれば要素は自動で正しく対応付けられる、と思うかもしれない。しかし実際に React が見ているのは要素の型と「ツリー内の位置」だけで、中身が意味的に同じデータを表しているかまでは判断していない。React は同じ位置に同じ型の要素が存在し続ける限り、それを「同一のコンポーネントインスタンス」とみなして state を引き継ぐ。配列を map でレンダリングするときに付ける key は、この同一性判定を位置ではなく値で行わせるための識別子だが、指定しないと React は配列のインデックスを暗黙の key として使う。

// key を指定しない = インデックスが識別子になる
items.map((item, i) => <Row data={item} />)          // 非推奨
items.map((item, i) => <Row key={i} data={item} />)  // 明示してもインデックスなら同じ問題が残る
items.map((item) => <Row key={item.id} data={item} />) // 安定した id を使う

配列の先頭を削除すると、残る要素のインデックスは全員 1 つずつ前にずれる。React から見ると「インデックス 0 の位置にある同一インスタンスに、新しい data が渡された」だけなので、そのインスタンスが内部に持っていた state(チェック状態や入力中のテキストなど)はそのまま残り、表示内容だけが 1 つずつずれて見える。一意で変化しない id を key に使えば、React は要素ごとの同一性を位置と無関係に追跡できるため、削除された要素の state だけが消え、残りは正しく元の要素に紐づいたままになる。

Fiber と automatic batching — もう一段深い最適化

React 15 までの reconciler は、差分計算を再帰呼び出し一発で最後まで実行していた。ツリーが大きいと、この計算が終わるまでメインスレッドはブラウザの他の作業(ユーザー入力の処理や描画)に戻れず、アニメーションのカクつきなどにつながっていた。React 16 で導入された Fiber は、この計算を「小さな作業単位」の連なりに分割し、各単位を終えるたびに制御をブラウザへ返せるかどうかを確認できる構造にした。これにより、優先度の高い更新を先に処理したり、低優先度の作業を後回しにしたりできるようになった。

React 18 の automatic batching は、この基盤の上に立つ最適化のひとつである。React 17 までは setTimeoutPromise のコールバック内で複数回 state を更新すると、そのたびに再レンダリング(=新しいツリー生成と diff)が走っていた。React 18 からはこうした文脈でも複数の更新が自動的に 1 回の再レンダリングへまとめられ、同じ結果に対する diff 計算の回数そのものが減る。

💼 実務でどう出会うか

一覧をフィルタ・並び替えした途端に、入力途中のテキストやチェック状態が別の行に「移動」して見えるバグの多くは、リストの key に配列インデックスを使っていることが原因である。タブ切り替えやチャット相手の切り替えのように「別物として state をリセットしたい」場面では、逆に意図的に key を変えてコンポーネントを丸ごと作り直させるテクニックも使われる。

⌨️ 手を動かす(5 分)

index を key にすると要素の削除時に state がずれる様子を、ブラウザだけで確認する。React を CDN から読み込む単一 HTML ファイルを作り、チェックボックスの状態が「行」ではなく「位置」に紐づくことを目で見る。

mkdir -p /tmp/vdom-demo && cd /tmp/vdom-demo
cat > index.html <<'EOF'
<!DOCTYPE html><html><body>
<div id="root"></div>
<script src="https://unpkg.com/react@18/umd/react.development.js"></script>
<script src="https://unpkg.com/react-dom@18/umd/react-dom.development.js"></script>
<script>
const { useState, createElement: h } = React;
function Item({ label }) {
  const [checked, setChecked] = useState(false);
  return h('label', null,
    h('input', { type: 'checkbox', checked, onChange: () => setChecked(c => !c) }),
    ' ' + label + '(このチェックは Item 内部の state)'
  );
}
function App() {
  const [items, setItems] = useState(['A', 'B', 'C']);
  const [byIndex, setByIndex] = useState(true);
  return h('div', null,
    h('button', { onClick: () => setByIndex(v => !v) }, 'key方式: ' + (byIndex ? 'index' : 'id')),
    ' ',
    h('button', { onClick: () => setItems(items.slice(1)) }, '先頭(A)を削除'),
    h('ul', null, items.map((label, i) =>
      h('li', { key: byIndex ? i : label }, h(Item, { label }))
    ))
  );
}
ReactDOM.createRoot(document.getElementById('root')).render(h(App));
</script>
</body></html>
EOF
open index.html

「key方式: index」のまま B と C だけチェックを入れてから「先頭(A)を削除」を押すと、削除後も 2 つのチェックが B・C の位置に残る(=A のあった位置の state が繰り上がって表示だけ変わった証拠)。ボタンで「key方式: id」に切り替えて同じ操作をすると、A の分だけチェックが消え、B・C のチェックは元のままになるはずだ。

🙅 よくある誤解
  • 仮想 DOM を使えば常に直接 DOM 操作より速い — 小さな 1 箇所の更新だけなら、直接 DOM を書き換える方が速い場合もある。仮想 DOM の利点は速度そのものの保証ではなく、宣言的に「全部作り直す」コードを書いても、実 DOM への反映だけは差分に絞り込める仕組みを提供する点にある。
  • key は一覧の各行に一意な ID を振るだけの飾りkey は React が「どの実 DOM ノードと state を引き継ぐか」を決める同一性のヒントである。指定しないとツリー内の位置で同一性が決まるため、並び替えや削除で state が意図しない要素に付いて回る。
  • state が変わるたびにツリーを全部作り直すのは無駄が多い設計 — 無駄なのは実 DOM を毎回丸ごと作り直すことであって、仮想 DOM ツリー(ただの JS オブジェクト)を作ること自体はレイアウト計算を伴わないため軽い。無駄になりうる工程を軽い工程に押し出したのが、この設計の要点である。
📖 用語ミニ辞典
仮想 DOM (Virtual DOM)
タグ名・属性・子要素を表すだけの軽量な JS オブジェクトのツリー。実 DOM ではないため生成コストが低い。
reconciliation
新旧 2 つの仮想 DOM ツリーを比較し、実 DOM への反映を最小限の差分に絞り込む React の処理全体。
diffing
reconciliation の中で行われる、2 つのツリーを比較して変化箇所を見つける具体的な計算。
key
リスト内の要素に付ける識別子。React が要素の同一性を位置ではなく値で判断するためのヒント。
Fiber
React 16 で導入された reconciler の内部構造。差分計算を中断・再開可能な単位に分割する。
automatic batching
React 18 から、非同期コールバック内も含め複数の state 更新を 1 回の再レンダリングにまとめる仕組み。
🔗 もっと深く
  • React: Reconciliation — diffing アルゴリズムが O(n) に収まる 2 つの前提と、型が変わったときにツリーごと再構築される挙動を公式に解説している。
  • React: Preserving and Resetting State — state が「コンポーネント」ではなく「ツリー内の位置」に紐づくことを、公式ドキュメントの現行版が具体例付きで示している。
  • React v18.0 Blog: New Feature: Automatic Batching — React 17 以前は非同期コールバック内でバッチ化されなかった事実と、18 での変更点を公式ブログが比較コード付きで説明している。
  • React Fiber Architecture (acdlite) — React コアチームの Andrew Clark による解説で、旧 reconciliation 公式ドキュメントが Fiber の参考資料として直接リンクしている。
最近の記事

レスポンスは 200 のはずなのに、なぜ通信量が 0 バイトになるのか?

HTTPキャッシュは鮮度(サーバーに聞かずに使える期間)と検証(聞いてから使う判断)の2層構造で、no-cacheは保存禁止ではなく毎回検証を強制する指示。ETag/If-None-Matchの検証が一致すると304 Not Modifiedで本文送信を省略する仕組みを解説。
Network🌿 基礎⏱ 約 6 分2026-09-21

負荷が落ち着いたのに、なぜ HPA はしばらく Pod を減らしてくれないのか?

HPAは既定15秒間隔の制御ループでdesiredReplicas=ceil(現在数×実測/目標)を計算するだけの仕組み。既定10%のトレランスでフラッピングを防ぎ、scaleUpは0秒・scaleDownは既定300秒のstabilizationWindowSecondsという非対称設計で急な縮小を避ける理由を解説。
Container🌿 基礎⏱ 約 7 分2026-09-20

Kafka のコンシューマーを増やしたのに、なぜ処理速度は頭打ちになるのか?

Kafkaのパーティションはグループ内で同時に1コンシューマーにしか割り当てられず並列度の天井になる。パーティション数を減らせない設計、Eager/Cooperativeリバランスの違い、オートコミットの既定5秒間隔が重複処理を生む仕組みを解説。
Arch🌳 応用⏱ 約 8 分2026-09-19

EC2 はパスワードを一度も入力していないのに、なぜ S3 にアクセスできるのか?

IAMロールは信頼ポリシー(誰が借りるか)と許可ポリシー(何ができるか)の二階建てで、STSのAssumeRoleが既定1時間・最大12時間の一時クレデンシャルを発行する仕組み。EC2でのIMDS経由の自動更新と、ロール剥奪が最大1時間遅延する挙動を解説。
Infra🌿 基礎⏱ 約 7 分2026-09-18