コンテンツにスキップ

インタラクティブデモ

ダイヤモンド依存関係のグリッチを実際に見る

Section titled “ダイヤモンド依存関係のグリッチを実際に見る”

2つの派生値が同じソースに依存している場合(ダイヤモンド依存関係)、一部のリアクティブライブラリは不整合な中間状態(グリッチ)を発行します。このデモでは、その違いを自分の目で確認できます。

各キャンバスをクリックしてドラッグし、軌跡を描いてください。各ドットは、同じダイヤモンド依存関係グラフを使用するライブラリのリアクティブパイプラインによって配置されます:

mousePos (source: {x, y})
├── derivedX = map(pos => pos.x)
├── derivedY = map(pos => pos.y)
└── combine([derivedX, derivedY])
└── draw dot at {x, y} + check consistency

発行された各位置は最新のマウス位置と比較されます:

  • 青いドット — 発行された (x, y) が実際のマウス位置と一致(整合)。
  • 赤いドット — 一方の軸は更新されたがもう一方はまだ(グリッチ — ドットがドラッグパスから外れて表示される)。
ライブラリグリッチ理由
SynState0深さベースのトポロジカル更新により、subscriber への通知前にすべてのブランチがアトミックに解決される
RxJS多数combineLatest は各入力の変化のたびに実行されるため、derivedXderivedY より先に更新され、古いデータによる中間発行が発生する
Jotai0プルベースの派生により整合性を確保 — 派生 atom は常に最新の値を読み取る
MobX0computed 値はバッチされた reaction 内で遅延評価される — 読み取り時にはすべての入力が最新

派生値が2つだけの場合、4つのライブラリすべてのパフォーマンス差は無視できます。しかし、グラフが大きくなるにつれて RxJS の余分な発行は深刻なパフォーマンス問題になります — 以下のディープ依存関係チェーンデモをご覧ください。

実際のアプリケーションでは、ダイヤモンド依存関係は、共有状態から算出されるスタイル、派生 UI 値、フォームバリデーションなど、あらゆる場面で現れます。そしてグリッチは以下を引き起こします:

  • 視覚的なちらつき — UI がありえない中間状態をレンダリングする。
  • 無駄な作業 — 余分な発行のたびに不要な副作用が実行される。combineLatestNN 個の入力がある場合、ソース1回の更新で N+1N+1 回発行される — グラフが大きくなると O(N2)O(N^2) の急激な性能劣化を引き起こす(以下のデモで実演)。
  • ロジックのバグ — 不整合なデータで副作用が実行される。

SynState はこれらの問題をライブラリレベルで排除しているため、手動の回避策は不要です。


ディープ依存関係チェーン: 負荷時の伝搬

Section titled “ディープ依存関係チェーン: 負荷時の伝搬”

このデモは、各リアクティブフレームワークの直列依存関係チェーンの伝搬パフォーマンスを測定します。4つのライブラリすべてが同一のリアクティブグラフトポロジーを構築します: 各ノードが前のノードに依存する深さ NN のチェーンで、最終的な combine/derive がすべての N+1N+1 個の出力を読み取ります。

ヘビの尻尾がマウスを追従します — 各セグメントはチェーン内のステージで、線形補間(lerp)を使用して前のステージにスムーズに追従します。ヘビ全体を描画するために、すべての N+1N+1 個の出力(head + NN ステージ)が1つの combine に集約されます:

上図は N=3N = 3 の場合です。デモではスライダーで NN を調整できます。チェーンの深さを増加させて、カクつきや上昇する μs/update に注目してください。

  • 低い NN(10-50)の場合: 4つのライブラリすべてがスムーズにレンダリング。
  • 高い NN の場合: RxJS が最初にカクつき始める — Total updates カウンターを確認してその理由を見てください。
  • SynState、Jotai、MobX はより高い NN でもスムーズを維持しますが、リアクティブエンジンの更新ごとのオーバーヘッドにより、Jotai と MobX はより高い μs/update を示します。

高い N で RxJS が急激に劣化する理由

Section titled “高い N で RxJS が急激に劣化する理由”

Total updates カウンターを確認してください: RxJS は同じマウス移動に対して SynState の約 N+1N+1多くの更新を示します。combineLatestN+1N+1 個の入力のいずれかが値を発行するたびに実行されます。1回のマウス移動で、scan チェーンは順次伝搬します:

  1. source が発行 → combineLatest が実行(source のみ更新、scan はまだ古い値)
  2. scan₁ が発行 → combineLatest が再び実行
  3. scan₂ が発行 → また実行
  4. … すべての NN ステージについて

これらの N+1N+1 回の実行それぞれが N+1N+1 点のフルキャンバス再描画をトリガー → O(N2)O(N^2)。これは最初のデモで示したグリッチ問題が性能面で顕在化したものです。

ライブラリグラフの深さ伝搬マウスイベントごとの subscriber 呼び出しイベントごとの総作業量
SynStateNNpush(アトミック)1(アトミック combine)O(N)O(N)
JotaiNNpull(遅延)1(派生読み取り)O(N)O(N)、より高い定数係数
MobXNNpush(バッチ)1(autorun)O(N)O(N)、より高い定数係数
RxJSNNpush(即時)N+1N+1(combineLatest グリッチ)O(N2)O(N^2)

SynState、Jotai、MobX はすべてマウスイベントごとに subscriber を1回だけ呼び出し、すべて O(N)O(N) です。ただし更新ごとの定数係数は大きく異なります — SynState の直接関数呼び出しは、Jotai の DFS 再計算や MobX のプロキシベース追跡よりもはるかに軽量です。次のデモでこの違いを分離して示します。


スループット: 負荷時の更新ごとのオーバーヘッド

Section titled “スループット: 負荷時の更新ごとのオーバーヘッド”

このデモでは RxJS を除外し(O(N2)O(N^2) の増大が支配的になるため)、3つのグリッチフリーライブラリに焦点を当てて定数係数の違いを可視化します。

ボールが自動的に円軌道を周回します。各キャンバスは上記のスプリングデモと同じ深さ MM のリアクティブチェーンを実行します。違いは、マウスイベントごとに1回のソース更新ではなく、各アニメーションフレームで KK 回のソース更新が同期ループでチェーンを通じてプッシュされることです。

each animation frame:
for k in 0..K-1:
update source position (micro-step along orbit)
→ propagate through depth-M scan chain
→ subscriber records latest points
draw snake once

これにより更新ごとのオーバーヘッドが KK 倍に増幅されます。更新毎の処理時間が 16ms を超えると、アニメーションはフレーム落ちしてカクつきます。

  • 低い KK(1-100)の場合: 3つのライブラリすべてがスムーズにアニメーション。
  • KK を 500-1000 に増加: Jotai と MobX では ms/frame が 16ms を超えてアニメーションが目に見えてカクつく一方、SynState はスムーズなまま。
  • ms/frame インジケーターは 16ms(60fps の上限)を超えると赤に変わります。

KK 回のソース更新それぞれが完全な更新パイプラインを実行します:

Jotai:

  1. store.set()MM 個の依存先の DFS 無効化 → O(M)O(M)
  2. 再計算順序を決定する DFS トポロジカルソート → O(M)O(M)
  3. エポックチェックと動的依存関係追跡を伴う各 selectAtom の再計算 → O(M)O(M)

フレームごとの合計: K×O(M)K \times O(M)、高い定数係数(Map/WeakSet アロケーション、エポック比較、ティックごとに2回の DFS トラバーサル)。

MobX:

  1. head.set()reaction を実行 → runInActionMM ステージを通じて伝搬 → O(M)O(M)
  2. stage.set() が MobX の依存関係追跡システムに通知
  3. アクション後、autorun がすべての MM ステージを再読み取り → O(M)O(M)

フレームごとの合計: K×O(M)K \times O(M)、MobX のプロキシベースの追跡とリアクションスケジューリングによるステージごとのオーバーヘッドあり。

SynState のプッシュ型エンジンは、構築時に1回解決された伝搬順序で直接関数呼び出しを通じて同じチェーンを伝搬します — 更新ごとのグラフ走査なし、動的依存関係追跡なし、エポック管理処理なし。

ライブラリ更新ごとの作業K=500K=500, M=100M=100
SynState100回の直接関数呼び出し~2-5ms
Jotai2× DFS(100) + 100回のエポックチェック付き再計算~50-100ms
MobX100回の observable.set + autorun 再読み取り~30-80ms