Web●●●○○

リアクティビティ

Reactivity ・ りあくてぃびてぃ

読まれた依存を記録し、データが変わったら依存先だけを自動で更新する仕組み。宣言的UIの柱。

✦ 30秒で試す — 航海どこにも「更新せよ」と書いていないのに、画面が追いかけてくる税込だけに知らせが届いた瞬間、本体の表示は?予想してみる

概要

リアクティビティ(reactivity)は、データが変わったとき、そのデータに依存しているものが自動で追従して更新される仕組みです。もっとも身近な例はスプレッドシートです。セル B1 に「=A1*2」と書いておけば、A1 を書き換えた瞬間に B1 が勝手に再計算されます。「A1 が変わったら B1 も更新する」という手続きはどこにも書いていないのに、式の中で A1 を読んだという事実だけで伝播の経路が張られる — これがリアクティブな計算モデルです。

フロントエンドでは、この性質を状態(データ)と画面(DOM)の間に持ち込みます。count という値を表示に使っていれば、count を書き換えるだけで表示が追従します。Vue のリアクティブオブジェクト、SolidJS・Preact・Angular の signals(シグナル。値を包んで読み書きを見張る小さな入れ物)、Svelte 5 の runes などがこの系譜で、仮想DOMと並ぶ宣言的UI(状態を書けば画面が追従する書き方)のもう一つの柱です。

仮想DOM が「変わったかもしれない範囲をまるごと作り直して比べる」のに対し、リアクティビティは「どの値がどこで読まれたか」を最初から記録しておき、変わった値の依存先だけを直接更新します。この違いが、両者の性能特性と落とし穴の違いを生んでいます。

なぜ生まれたか

リアクティビティ以前、データと画面の同期は手作業でした。値を書き換えたら、その値を表示しているすべての箇所を開発者が思い出して、JavaScript で DOM を1つずつ更新する。表示箇所が増えるたびに更新コードも増え、1か所でも忘れれば画面とデータが食い違います。オブザーバパターン(変更を購読者に通知する設計)を手で組むこともできますが、購読の登録・解除や通知の配線を全部自前で管理するのは、それ自体が大きな負担でした。

この「変更の伝播を人間が配線する」仕事を、システムに肩代わりさせようというのがリアクティビティの発想です。ルーツはスプレッドシートや、学術的には FRP(関数型リアクティブプログラミング。1997年の Elliott と Hudak の Fran が起点)にあり、フロントエンドでは Knockout.js(2010年)のオブザーバブルや AngularJS のデータバインディングを経て、Vue が「普通のオブジェクトを書き換えるだけで画面が追従する」体験として大衆化しました。2020年代に入ると SolidJS が細粒度のシグナルで高い性能を示し、Preact・Angular・Svelte 5 が相次いでシグナルを採り入れます。2024年には JavaScript の言語仕様にシグナルを加える提案が TC39 に出され、Stage 1 で議論が続いています。

詳細

依存追跡 — 「読まれたこと」を記録する

リアクティビティの核心は依存追跡(dependency tracking)です。仕組みは意外と素朴で、(1)状態の読み取りを検知できるようにしておき、(2)計算や描画の実行中に「どの状態が読まれたか」を記録し、(3)その状態が書き換えられたら、記録しておいた計算だけを再実行します。「A1 が変わったら B1 を更新」という配線を、B1 の式を最初に評価したときに自動で張ってしまうのです。実行のたびに読んだものを記録し直すので、if の分岐で読まなくなった値からは自然に購読が外れます。

読み書きを検知する方式は世代ごとに進化してきました。Vue 2 は Object.defineProperty で各プロパティを getter/setter に置き換える方式で、後からのプロパティ追加や配列の添字代入を検知できない弱点がありました。Vue 3 や MobX は JavaScript の Proxy(オブジェクトへのあらゆる操作に割り込める言語機能)でこれを解決しています。SolidJS・Preact・Angular の signals は、値を count() や count.value のような明示的な入れ物に包む方式で、何がリアクティブかをコード上で明確にします。

Svelte は立ち位置が変わった例です。Svelte 4 までは、コンパイラがコンポーネント内の let への代入を見つけて、該当する DOM を書き換えるコードを織り込む方式で、実行時の依存追跡を持ちませんでした。2024年の Svelte 5 は $state・$derived・$effect という runes に切り替え、コンパイラは残しつつも、実行時にはシグナルで依存を追跡します。コンポーネントの外(普通の .svelte.js ファイル)でも同じ仕組みが使えるようになったのは、この切り替えのおかげです。

仮想DOM方式との対比 — 作り直して比べるか、依存をたどって直撃するか

宣言的UIを実現する路線は大きく2つあります。仮想DOM方式は「状態が変わったらコンポーネントを再実行して木を作り直し、差分を探す」— 変更箇所を毎回計算で突き止める方式です。リアクティブ方式は「どの状態がどこで使われているかを最初から知っている」ため、差分計算をせず依存グラフをたどって該当箇所だけを直接更新します。

仮想DOM方式状態が変わるコンポーネントを再実行し仮想木を丸ごと再生成前回の木と差分検出差分だけ実DOMを更新更新箇所を毎回「計算して見つける」リアクティブ方式シグナルが変わる記録済みの依存グラフをたどる依存するDOMノードだけ更新更新箇所を最初から「知っている」
同じ宣言的UIでも更新戦略が異なる — 作り直して比べるか、依存をたどって直撃するか

どちらが優れているという話ではなく、設計思想の違いです。仮想DOM方式はコンポーネント単位の粗い粒度で動くため、モデルが単純で「毎回全部書き直す」感覚を貫けます。リアクティブ方式は細粒度(fine-grained)で無駄がない代わりに、依存グラフという状態機械を裏に抱えます。React 以外の主要フレームワークは signals 系へ収斂しつつあり、Vue も 3.6 に向けて、仮想DOMを通さずテンプレートを直接 DOM 操作へコンパイルする Vapor モードを用意しています。一方の React は、2025年に安定版になった React Compiler(自動メモ化)で「作り直す範囲を減らす」方向から同じ問題に取り組んでいます。

派生値・副作用と、菱形依存のグリッチ

実用的なリアクティブシステムは、生の状態(signal / ref)に加えて、他の状態から計算される派生値(computed / memo。結果はキャッシュされ、依存が変わったときだけ再計算)と、DOM 更新や通信のような副作用を実行する仕掛け(effect / watch)の三点セットで構成されます。

難しいのは、依存が菱形につながったときです。状態 A から派生値 B と C が計算され、D が B と C の両方を読んでいるとします。A を書き換えたとき、通知が届いたそばから再計算する素朴な実装では、B が新しくなった直後に D が再計算され、その時点で C はまだ古いままです。D は「新しい B × 古い C」という、どの時点にも存在しない値を一瞬作ってしまいます。これをグリッチ(glitch)と呼びます。

状態 A派生値 B派生値 C派生値 DどちらもAから計算Dは両方を読む素朴に伝播すると…A更新 → B更新 → 即Dを再計算この時点で C はまだ古いまま→ 新しいB × 古いC の不整合な値が  一瞬 D に現れる(グリッチ)リアクティブ系の答え「古い」の印を先に配り、依存の深さの順にBとCが確定してから D を一度だけ計算
菱形依存とグリッチ — 素朴に伝播すると D が不整合な中間値で一瞬計算されてしまう

現代のシグナル実装はこれを二段階で防ぎます。まず状態が書き換わった瞬間に、下流の派生値へ「古いかもしれない」という印だけを配ります(push)。値の計算はまだしません。次に、画面を更新する effect が値を読みにいったとき、印の付いた派生値を依存の深さの順(トポロジカル順)にたどって計算し直します(pull)。B と C が確定してから D を一度だけ計算するので、グリッチは起きず、読まれない派生値は計算すらされません。この「印は押し出し、値は引き出す」方式は push-pull と呼ばれ、Preact signals や Vue 3.6 が採り入れる alien-signals、TC39 の提案もこの形をとっています。

下の部品で確かめてください。「価格」を上下にドラッグすると、価格を読んでいた箱にだけ光が伝わり、何も読んでいない見出しは一度も触られません。伝え方を「届いた順に即時」にすると、菱形の合流点「本体」が一瞬おかしな値を表示し、DOM への書き込みも増えます。

⚡ 体験: 状態を書き換えて、依存グラフの上を伝わる更新を見る
状態価格¥1,000状態数量2 個派生税込 = 価格×1.1¥1,100派生税額 = 価格×0.1¥100派生本体 = 税込−税額¥1,000派生合計 = 税込×数量¥2,200DOM<p> 本体¥1,000DOM<p> 税額¥100DOM<p> 合計¥2,200DOM<h1> 見出しお会計
伝え方
派生値の再計算0 回DOM 書き込み0 回不整合な表示0 回

金の枠の「価格」の箱を上下にドラッグして書き換えてください(▲▼ を押しても変わります)。その値を読んでいた箱にだけ光が伝わります。

落とし穴

落とし穴も依存追跡ゆえのものです。追跡できない書き換え(Proxy を通さない生オブジェクトへの代入、配列の中身を直接いじる操作など)は画面に反映されません。逆に、effect の中で自分が読んでいる状態を書き換えると、更新が自分を呼び直して循環します。派生値で済むものを effect で別の状態へ書き写すのは、この循環とグリッチの両方を招く典型的な誤りで、各フレームワークの文書が「effect で状態を同期しない」と繰り返し注意しています。

また「魔法のように動く」ことの裏返しで、どこで何が購読されているかが追いにくくなります。大きなアプリでどこから更新が来るかを整理するのは状態管理の仕事です。魔法の正体は「読み取りの記録と通知の配線」だと押さえておけば、挙動が不思議に見えたときに立ち返る場所ができます。

試問

スプレッドシートのように式で結ばれた画面です。状態「価格」「数量」から、税込(価格×1.1)・税額(価格×0.1)・本体(税込−税額)・合計(税込×数量)が計算され、右の DOM(画面の要素)に書き込まれます。「価格」の箱を上下にドラッグするか ▲▼ を押すと書き換わり、変更が依存している箱へ伝わります。変更の伝え方は2通りから選べます。

✦ 命題挑戦中

「価格」を書き換えても、本体に一度も食い違った値を見せず、DOM への書き込みを3回だけで済ませよ

  • 「価格」を書き換え、更新を最後まで伝える
  • 本体が一度も価格と食い違う値を表示しない
  • DOM への書き込みは 3回(価格に依存する3つの DOM に1回ずつ)
⚡ 体験: 状態を書き換えて、依存グラフの上を伝わる更新を見る
状態価格¥1,000状態数量2 個派生税込 = 価格×1.1¥1,100派生税額 = 価格×0.1¥100派生本体 = 税込−税額¥1,000派生合計 = 税込×数量¥2,200DOM<p> 本体¥1,000DOM<p> 税額¥100DOM<p> 合計¥2,200DOM<h1> 見出しお会計
伝え方
派生値の再計算0 回DOM 書き込み0 回不整合な表示0 回

金の枠の「価格」の箱を上下にドラッグして書き換えてください(▲▼ を押しても変わります)。その値を読んでいた箱にだけ光が伝わります。