JavaScript Signals徹底解説: なぜ全フレームワークがSignalsを採用しているのか(そしてReactの選択とは)
フロントエンドの世界で、ちょっと異常な動きが起きています。AngularがSignalsを導入した。Svelteは既存のリアクティビティモデルを全部捨ててRunesに置き換えた。Solid.jsは最初からSignals上に設計されている。VueのリアクティビティシステムはずっとSignalに近い仕組みで動いていた。Qwik、Preact、Emberまで、全部同じプリミティブに収束しています。
一方、市場シェアNo.1のReactだけが、明確に逆の方向に進んでいます。Signalsを使わず、コンパイラでパフォーマンス問題を解決するという賭けに出ているんです。
TC39 Signals提案は、このリアクティブプリミティブをJavaScript言語自体に組み込もうとしています。もし成功すれば、Signalsはフレームワーク機能じゃなくて、PromiseやArrayと同じJavaScript標準の一部になります。
Virtual DOMが登場して10年以上経ちますが、それ以来フロントエンドアーキテクチャ最大の転換点なんですよね。実際に何が起きているのか、なぜ重要なのか、今書いているコードにどう影響するのかを一つずつ見ていきましょう。
Signalって正確には何なのか
フレームワーク固有のAPIを全部取り払うと、Signalは驚くほどシンプルな概念です: 値が変わったら依存先に自動で通知するリアクティブコンテナ。
依存関係の追跡が自動で行われるObservable変数と考えてください。ある計算の中でSignalを読むと、ランタイムがその依存関係を記憶します。Signalの値が変われば、実際にその値に依存している計算だけが再実行されます。
// リアクティブな値を作成 const count = signal(0); // 派生計算 — 依存関係を自動追跡 const doubled = computed(() => count.value * 2); // 副作用 — 依存している値が変わったときだけ再実行 effect(() => { console.log(`Count: ${count.value}, doubled: ${doubled.value}`); }); // countに依存しているものだけが再実行される count.value = 5; // コンソール: "Count: 5, doubled: 10"
依存配列がない。手動のサブスクリプション管理もない。Diffingアルゴリズムもない。ランタイムが実行時に依存グラフを観察して、何が何に依存しているか正確に把握しているからです。
3つのプリミティブ
どのフレームワークのSignals実装も、3つのプリミティブで構成されています:
1. Signal(状態) — 単一の値を保持するリアクティブコンテナ。
const name = signal("Alice"); console.log(name.value); // "Alice" name.value = "Bob"; // 依存先に通知
2. Computed(派生状態) — 1つ以上のSignalから導出される値。Lazyに動作して、読まれたときだけ、かつ依存関係が変わった場合のみ再計算します。
const firstName = signal("Alice"); const lastName = signal("Smith"); const fullName = computed(() => `${firstName.value} ${lastName.value}`); // fullNameは読まれて、かつ依存関係が変わったときだけ再計算
3. Effect(副作用) — 追跡された依存関係が変わると実行される関数。DOM更新、ネットワークリクエスト、ロギングはここで行います。
effect(() => { document.title = fullName.value; // fullNameが変わったときだけ再実行 });
この3つのプリミティブモデルが土台です。では、内部がどう動いているか深掘りしていきましょう。
Signalsの内部動作
Signalsの真髄はAPIじゃなくて、自動依存関係追跡にあります。原理を理解するには実際に作ってみるのが一番早いです。シンプルなSignalsランタイムをゼロから作ってみましょう。
依存グラフ
Signalsランタイムの内部には、有向非巡回グラフ(DAG)があります:
┌─────────┐ ┌─────────┐
│ signal A │────▶│computed C│────▶ effect E
└─────────┘ └─────────┘
┌─────────┐ ▲
│ signal B │────────┘
└─────────┘
Signal Aが変わると、ランタイムはグラフをたどってComputed CとEffect Eだけを再実行します。Signal Bの他の依存先(あれば)は一切触りません。
50行で作るSignals
実際に動くSignalsが~50行のJavaScriptで書けます:
let currentObserver = null; function signal(initialValue) { let value = initialValue; const subscribers = new Set(); return { get value() { // 追跡: 誰かが観察中なら、このsignalを登録 if (currentObserver) { subscribers.add(currentObserver); } return value; }, set value(newValue) { if (newValue === value) return; // 同じならスキップ value = newValue; // 通知: 全サブスクライバーを再実行 for (const subscriber of subscribers) { subscriber(); } } }; } function computed(fn) { let cachedValue; let dirty = true; const computation = () => { dirty = true; }; return { get value() { if (dirty) { const prevObserver = currentObserver; currentObserver = computation; cachedValue = fn(); currentObserver = prevObserver; dirty = false; } return cachedValue; } }; } function effect(fn) { const execute = () => { const prevObserver = currentObserver; currentObserver = execute; fn(); currentObserver = prevObserver; }; execute(); // 即座に実行して依存関係を確立 }
キーとなる技法はグローバルオブザーバースタック(currentObserver)です。ComputedやEffectが実行されるとき、自分自身を現在のオブザーバーとして設定します。その実行中に読まれるSignalは、自動的にオブザーバーをサブスクライバーセットに追加します。だから依存関係を手動で宣言する必要がないんです。
プロダクション向け最適化
実際の実装は、この素朴なアプローチに対していくつかの重要な最適化を加えています:
1. Push-Pull評価 — Signalが変わったときに下流のComputedを即座に再実行するのではなく、"dirty"としてマーク(push)して、実際に値が読まれたときだけ再計算(pull)します。大きなグラフで不要な計算を完全に回避できます。
2. グリッチフリー実行 — Signal AとBが同じマイクロタスクで両方変わった場合、両方に依存するComputedは2回じゃなく1回だけ実行されるべきです。トポロジカルソートやバッチ処理で一貫性を保証します。
const a = signal(1); const b = signal(2); const sum = computed(() => a.value + b.value); batch(() => { a.value = 10; b.value = 20; }); // sumは(10, 20)で1回だけ実行
3. 自動クリーンアップ — Effectが再実行されると、以前の依存関係サブスクリプションが自動的に解除されて新しく確立されます。メモリリーク防止。
4. 等値チェック — 新しい値が前の値と同じなら(デフォルトはObject.is)、通知自体をスキップして不要な下流更新を防ぎます。
TC39 Signals提案: 言語標準になるSignals
一番エキサイティングなのは、どのフレームワーク内部でもなく言語レベルで起きていることです。TC39 Signals提案は、リアクティブプリミティブをJavaScript標準に直接入れようとしています。
なぜ標準化が必要なのか
各フレームワークが独自のSignals実装を持っています。Angular SignalsとSolidのcreateSignalは相互運用できません。Preact Signalsで作ったdate pickerライブラリは、ラッパーなしにVueアプリでは動きません。
TC39提案は、全フレームワークが上に構築できる標準Signal APIでこの問題を解決します:
// TC39提案API(Stage 1、変更の可能性あり) const counter = new Signal.State(0); const isEven = new Signal.Computed(() => (counter.get() & 1) === 0); // フレームワークはそれぞれの使いやすいAPIでラップするが // 内部のリアクティブグラフは共有される
提案が提供するもの
提案はレンダリング層ではなく、リアクティブグラフアルゴリズムに集中しています:
Signal.State— 読み書き可能なリアクティブ値Signal.Computed— 派生リアクティブ値(lazy、キャッシュ)Signal.subtle.Watcher— フレームワーク統合用のローレベルAPI
重要なポイント: 提案に**effect()は含まれていません**。DOM更新、レンダースケジューリング、変更バッチングはフレームワーク固有の領域だからです。標準はリアクティブグラフプリミティブだけを提供します。
相互運用性のビジョン
こんな未来を想像してみてください:
- チャートライブラリが内部で
Signal.Stateを使用 - AngularアプリでAngular Signals(
Signal.Stateベース)を通じてそのライブラリを使う - 同僚は同じライブラリをSolidアプリで使う
- グラフが共有されているからリアクティビティがシームレスに流れる
フレームワークに関係なく状態のリアクティビティが共有される世界。これがTC39標準化が目指す未来です。
フレームワーク別Signals事情
Angular Signals(v17+)
RxJSとZones、変更検知で有名だったAngularがSignalsを導入したのは、正直驚きました。これは大きいんですよね:
import { signal, computed, effect } from '@angular/core'; @Component({ template: ` <h1>{{ fullName() }}</h1> <button (click)="updateName()">名前変更</button> ` }) export class UserComponent { firstName = signal('Alice'); lastName = signal('Smith'); fullName = computed(() => `${this.firstName()} ${this.lastName()}`); logger = effect(() => { console.log(`名前が変わりました: ${this.fullName()}`); }); updateName() { this.firstName.set('Bob'); } }
アーキテクチャ上の核心的変化: Angularが変更検知でZone.jsを完全にスキップできるようになったんです。すべてのイベントでコンポーネントツリー全体をdirty-checkingする代わりに、変更されたSignalにバインドされた特定のDOMノードだけを更新します。結果、実際のベンチマークでレンダリング速度30-50%向上。これはデカいです。
Solid.js — 初日からSignals
Solid.jsは、SignalsがプロダクションレベルのUIフレームワークを動かせることを証明しました:
import { createSignal, createMemo, createEffect } from "solid-js"; function Counter() { const [count, setCount] = createSignal(0); const doubled = createMemo(() => count() * 2); createEffect(() => { console.log(`Count: ${count()}, Doubled: ${doubled()}`); }); return ( <button onClick={() => setCount(c => c + 1)}> {count()} × 2 = {doubled()} </button> ); }
Reactとの決定的な違い: Solidはコンポーネントを再実行しません。Counter関数はちょうど1回だけ実行されます。JSXは特定のSignalをサブスクライブする細粒度のDOM更新命令にコンパイルされます。countが変わると、count()とdoubled()を表示するテキストノードだけが更新されて、コンポーネント関数自体は絶対に再実行されません。
Virtual DOM diffingなし、再レンダリングなし、メモ化の必要なし。一切。
Svelte Runes(v5)
Svelte 5は以前の$:ラベル構文ベースのリアクティビティをRunesに完全置換しました。本質的にはコンパイル時Signalsです:
<script> let count = $state(0); let doubled = $derived(count * 2); $effect(() => { console.log(`Count: ${count}, Doubled: ${doubled}`); }); </script> <button onclick={() => count++}> {count} × 2 = {doubled} </button>
Runesの優雅さは、普通の変数に見えるところです。コンパイラが$state、$derived、$effectを内部のSignalプリミティブに変換しますが、開発者体験は普通のJavaScriptを書いている感覚なんです。
VueのReactivity(Composition API)
VueはComposition API(Vue 3)以来、内部的にはVue 2のObject.defineProperty以来、Signal的なリアクティビティシステムを使ってきました:
<script setup> import { ref, computed, watchEffect } from 'vue'; const count = ref(0); const doubled = computed(() => count.value * 2); watchEffect(() => { console.log(`Count: ${count.value}, Doubled: ${doubled.value}`); }); </script> <template> <button @click="count++"> {{ count }} × 2 = {{ doubled }} </button> </template>
VueのrefがSignal。computedがComputed。watchEffectがEffect。名前は違うけど、内部のリアクティブグラフはアーキテクチャ的に同一です。Signalsがトレンドになるずっと前から、VueはSignalsをやっていたんですよね。
Preact Signals
Preactはちょっと変わったアプローチを取りました。Virtual DOMに統合するコンパニオンライブラリとしてSignalsを追加したんです:
import { signal, computed } from "@preact/signals"; const count = signal(0); const doubled = computed(() => count.value * 2); function Counter() { return ( <button onClick={() => count.value++}> {count} × 2 = {doubled} </button> ); }
Preact Signalsの特筆すべき点: JSXにSignalを直接渡せます({count.value}じゃなく{count})。PreactがDOMレベルで直接サブスクライブして、そのノードに関してはVirtual DOM diffを完全にバイパスします。React風のコンポーネントモデルを維持しながら、Solidに近いパフォーマンスが出ます。
Reactの異なる道: Hooks + コンパイラ vs. Signals
ここから話が熱くなります。すべての主要フレームワークがSignalsに収束しているのに、Reactだけが採用しない選択をしている。なぜなのか。その理由を理解すると、根本的な哲学の違いが見えてきます。
ReactのSignals反対論
1. トップダウンデータフロー — Reactはコンポーネントがpropsとstateの関数であるという設計思想の上に成り立っています。SignalsはDOMノードを直接更新するので、このモデルを壊すことになります。
2. デバッグのしやすさ — Reactのモデルでは、コンポーネントにブレークポイントを置けばすべての状態変更で完全なレンダーを確認できます。Signalsだと更新が細粒度で起きるので、ステップスルーする「レンダーサイクル」がありません。
3. コンパイラへの賭け — Reactの立場は、コンパイラがプログラミングモデルを変えなくてもSignalレベルのパフォーマンスを出せるということです。
反論
1. 根本的なオーバーヘッド — 完璧なメモ化をしても、Reactは依然としてコンポーネント関数を再実行し、Virtual DOMツリーをdiffしています。Signalsはその両方のステップを丸ごとスキップ。コンパイラでは突破できないパフォーマンスの天井があるんですよ。
React(コンパイラ適用後):
状態変更 → コンポーネント関数を再実行 → vDOM diff → DOM patch
Signals:
状態変更 → DOMを直接patch
// 「再実行 + diff」のステップはどんなに最適化してもコストがゼロにはならない
2. ランタイムコスト — React Compilerのメモ化はランタイムオーバーヘッド(キャッシュルックアップ、すべてのメモ化値に対する等値チェック)を追加します。Signalsは値が実際に変わったときだけ作業します。
3. 「Reactのルール」 — コンパイラはコードが「Reactのルール」(純粋コンポーネント、レンダー中のミューテーション禁止、正しいHook順序)に従うことを要求します。違反するとパフォーマンス問題じゃなく、正確性のバグが静かに発生します。Signalsにはそんな制約はありません。
4. エコシステムの分断 — TC39がSignalsを標準化すれば、React以外のすべてのフレームワークが共通のリアクティブプリミティブを共有します。Reactコンポーネントだけが仲間外れになるわけです。
パフォーマンス比較
js-framework-benchmark(フレームワークパフォーマンスの標準化ストレステスト)の公開結果に基づく概算値で、10,000行テーブルの結果です:
| 操作 | React 19 + Compiler | Solid.js (Signals) | Angular (Signals) | Svelte 5 (Runes) |
|---|---|---|---|---|
| 10k行作成 | ~420ms | ~190ms | ~230ms | ~200ms |
| 10行ごとに更新 | ~80ms | ~18ms | ~25ms | ~20ms |
| 2行スワップ | ~45ms | ~12ms | ~15ms | ~14ms |
| 行選択 | ~8ms | ~2ms | ~3ms | ~2ms |
| 行削除 | ~38ms | ~6ms | ~9ms | ~7ms |
| メモリ(作成後) | ~9 MB | ~4 MB | ~4.5 MB | ~3.5 MB |
注: これらは公開されたベンチマークのトレンドに基づく概算値です。実際の数値はハードウェア、ブラウザ、フレームワークバージョンによって異なります。最新の結果はjs-framework-benchmarkでご確認ください。
パターンは一貫しています: Signalsベースのフレームワークが、Reactのコンパイラ最適化アプローチに対してほとんどの操作で概ね2-4倍速い。メモリの差はさらに劇的で、SignalsはVirtual DOMツリーを保持する必要がないからです。
実践: 今のスタックにSignalsを導入する
Signalsを今すぐ導入したい場合、現在使っているフレームワークに応じた実用的なパスがあります。
Angularを使っている場合
もう既にそこにいます。Angular 17+ Signalsはプロダクションレディです。RxJS中心のパターンから移行を始めましょう:
// Before: RxJS Observables @Component({...}) export class UserComponent implements OnInit, OnDestroy { user$!: Observable<User>; private destroy$ = new Subject<void>(); ngOnInit() { this.user$ = this.userService.getUser().pipe( takeUntil(this.destroy$) ); } ngOnDestroy() { this.destroy$.next(); this.destroy$.complete(); } } // After: Angular Signals + resource API @Component({...}) export class UserComponent { userId = input.required<string>(); user = resource({ request: () => this.userId(), loader: ({ request: id }) => this.userService.getUser(id) }); }
サブスクリプション管理不要。takeUntilパターン不要。OnDestroyクリーンアップ不要。
Reactを使っている場合(Preact Signalsの活用)
Reactでも@preact/signals-reactを通じてSignalsを使えます:
import { signal, computed } from "@preact/signals-react"; const count = signal(0); const doubled = computed(() => count.value * 2); function Counter() { return ( <div> <p>{count.value} × 2 = {doubled.value}</p> <button onClick={() => count.value++}>インクリメント</button> </div> ); }
注意点: サードパーティの統合なので、Reactのレンダーサイクルにフックして動作します。ネイティブSignalsの完全なパフォーマンスメリット(vDOMバイパス)は得られませんが、useMemo/useCallbackを手書きする苦行からは解放されます。これだけでも十分ありがたいんですよね。
ゼロから始める場合
2026年に新プロジェクトでフレームワークを選ぶとき、パフォーマンスが重要なら:
- Solid.js — 最高パフォーマンス、最小バンドルサイズ、最も「純粋な」Signals体験
- Svelte 5 — 最高の開発者体験(Runesが普通のJSに見える)、優れたパフォーマンス
- Angular — 大規模エンタープライズチームに最適(TypeScriptネイティブ、包括的ツーリング)
- Vue — パフォーマンスとエコシステム成熟度のバランスが良い
実践例: リアクティブフォームバリデーション
Signalsが実際にどう動くか、実用的な例を作ってみましょう。
Vanilla Signals(TC39提案スタイル)
const email = new Signal.State(""); const password = new Signal.State(""); const emailError = new Signal.Computed(() => { const value = email.get(); if (!value) return "メールアドレスは必須です"; if (!value.includes("@")) return "メールアドレスの形式が正しくありません"; return null; }); const passwordStrength = new Signal.Computed(() => { const value = password.get(); if (value.length === 0) return "empty"; if (value.length < 8) return "weak"; if (/(?=.*[A-Z])(?=.*[0-9])(?=.*[!@#$%])/.test(value)) return "strong"; return "medium"; }); const isFormValid = new Signal.Computed(() => { return emailError.get() === null && passwordStrength.get() !== "weak" && passwordStrength.get() !== "empty"; }); // この派生グラフが自動更新されます: // email変更 → emailError再計算 → isFormValid再計算 // password変更 → passwordStrength再計算 → isFormValid再計算 // 手動での依存関係配線は不要
Solid.js実装
import { createSignal, createMemo, Show } from "solid-js"; function SignupForm() { const [email, setEmail] = createSignal(""); const [password, setPassword] = createSignal(""); const emailError = createMemo(() => { const value = email(); if (!value) return "メールアドレスは必須です"; if (!value.includes("@")) return "メールアドレスの形式が正しくありません"; return null; }); const passwordStrength = createMemo(() => { const value = password(); if (value.length === 0) return "empty"; if (value.length < 8) return "weak"; if (/(?=.*[A-Z])(?=.*[0-9])(?=.*[!@#$%])/.test(value)) return "strong"; return "medium"; }); const isValid = createMemo(() => emailError() === null && !["weak", "empty"].includes(passwordStrength()) ); return ( <form> <input type="email" value={email()} onInput={(e) => setEmail(e.target.value)} classList={{ error: !!emailError() }} /> <Show when={emailError()}> <span class="error">{emailError()}</span> </Show> <input type="password" value={password()} onInput={(e) => setPassword(e.target.value)} /> <div class={`strength-${passwordStrength()}`}> 強度: {passwordStrength()} </div> <button disabled={!isValid()}>登録</button> </form> ); }
ユーザーがメールフィールドに入力すると:
emailSignalだけが変更emailErrorだけが再計算(passwordStrengthは再計算されない)isValidだけが再計算emailError()とisValid()にバインドされたDOMノードだけが更新
パスワード入力、強度インジケーター、その他すべてのDOMノードは一切触られません。Reactだったら、コンポーネント関数全体が再実行されて、すべてのJSX表現が再評価されて、Virtual DOMサブツリー全体のdiffが走っていたでしょう。
フロントエンドが向かう先
Signalsへの収束は偶然じゃありません。複数の力が同時に作用しています:
1. Virtual DOMのパフォーマンスの天井
Virtual DOM diffingは2013年には素晴らしいアイデアでした。JavaScriptエンジンが遅くてDOM操作が高価だった時代です。2026年のJavaScriptエンジンは驚異的に速くて、「仮想ツリー作成 → diff → patch」のオーバーヘッド自体がボトルネックになっています。
Signalsは中間層を排除します。状態変更 → DOM更新。diffingステップなし。
2. Islandsアーキテクチャの台頭
Astro、Qwik、Next.js(RSC)までもが、静的HTMLの海の中のインタラクティブな「島」モデルに移行しています。Signalsは各Islandがページの残りに影響を与えずに独自のローカルリアクティブグラフを持てるので、自然にフィットします。
3. TC39のエンドゲーム
Signal.StateとSignal.ComputedがJavaScript標準の一部になったら:
- その上に構築されたすべてのフレームワークが自動的に相互運用
- ブラウザエンジンがネイティブレベルでリアクティブグラフを最適化可能
- サードパーティライブラリ(date picker、フォームライブラリ、状態管理)が汎用リアクティブプリミティブを使用
状態管理の「フレームワークロックイン」時代の終わりです。
まとめ
フロントエンドの世界でリアクティビティ革命が進行中です。Signalsは、Reactが普及させたVirtual DOM diffingアプローチよりも根本的に効率的なUI状態管理モデルであることを証明しました。
React以外のすべての主要フレームワークがSignalsを導入しました。TC39提案はJavaScript自体への標準化を進めています。Angularは移行後30-50%のレンダリング改善を達成。Solid.jsは標準ベンチマークでReact比2-4倍のパフォーマンスを出しています。
Reactのコンパイラへの賭けは大胆で、差を縮めるかもしれません。しかし、コンポーネント再実行とVirtual DOMツリーdiffingという根本的なアーキテクチャオーバーヘッドは排除できません。ReactがいずれSignalsを採用するのか、コンパイラアプローチの優位性を証明するのか。いずれにせよ、この競争がエコシステム全体をより良くしています。
どのフレームワークを使っていても、Signalsを理解することはもうオプションじゃありません。リアクティブグラフがフロントエンド状態管理の未来であり、もうすでにここに来ています。
関連ツールを見る
Pockitの無料開発者ツールを試してみましょう