コンテンツにスキップ

パフォーマンスベンチマーク

各ライブラリのリアクティブプリミティブの純粋なオーバーヘッドを測定するシンプルな線形チェーンです:

  • ループ: counter を 11 から NN まで同期的に更新(N=105N = 10^5).
  • 検証: 最終値 =N×4= N \times 4.
  • ダイヤモンド依存関係なし — 純粋な伝搬オーバーヘッドの比較。
LibraryMedian (ms)Min (ms)Max (ms)p95 (ms)Ops/sec
SynState13.3910.2820.0419.627,465,632
RxJS3.793.674.124.1126,392,839
MobX89.3688.4890.7490.141,119,054
Jotai392.07374.80426.35420.00255,059
Redux202.22191.80251.83242.93494,500
Zustand2.962.863.743.3833,751,925
Valtio22.0421.2522.8222.824,537,372

各ライブラリは同じ派生チェーンパターンを実装しています。すべての実装は vitest テストとして実行可能です。

export const runBenchmark = (n: number): number => {
const [counter, setCounter] = createState(0);
const doubled = counter.pipe(map((x) => x * 2));
const quadrupled = doubled.pipe(map((x) => x * 2));
let mut_lastValue = 0;
const subscription = quadrupled.subscribe((v) => {
mut_lastValue = v;
});
for (let mut_i = 1; mut_i <= n; mut_i++) {
setCounter(mut_i);
}
subscription.unsubscribe();
return mut_lastValue;
};
export const runBenchmark = (n: number): number => {
const counter = new BehaviorSubject(0);
const doubled = counter.pipe(map((x) => x * 2));
const quadrupled = doubled.pipe(map((x) => x * 2));
let mut_lastValue = 0;
const subscription = quadrupled.subscribe((v) => {
mut_lastValue = v;
});
for (let mut_i = 1; mut_i <= n; mut_i++) {
counter.next(mut_i);
}
subscription.unsubscribe();
return mut_lastValue;
};
export const runBenchmark = (n: number): number => {
const state = observable({ counter: 0 });
const doubled = computed(() => state.counter * 2);
const quadrupled = computed(() => doubled.get() * 2);
let mut_lastValue = 0;
const dispose = reaction(
() => quadrupled.get(),
(value) => {
mut_lastValue = value;
},
{ fireImmediately: true },
);
for (let mut_i = 1; mut_i <= n; mut_i++) {
runInAction(() => {
state.counter = mut_i;
});
}
dispose();
return mut_lastValue;
};
export const runBenchmark = (n: number): number => {
const counterAtom = atom(0);
const doubledAtom = atom((get) => get(counterAtom) * 2);
const quadrupledAtom = atom((get) => get(doubledAtom) * 2);
const store = createStore();
let mut_lastValue = store.get(quadrupledAtom);
const unsub = store.sub(quadrupledAtom, () => {
mut_lastValue = store.get(quadrupledAtom);
});
for (let mut_i = 1; mut_i <= n; mut_i++) {
store.set(counterAtom, mut_i);
}
unsub();
return mut_lastValue;
};
export const runBenchmark = (n: number): number => {
const counterSlice = createSlice({
name: 'counter',
initialState: { value: 0 },
reducers: {
set: (state, action: Readonly<{ payload: number }>) => {
state.value = action.payload;
},
},
});
const store = configureStore({
reducer: counterSlice.reducer,
middleware: () => new Tuple(),
});
// eslint-disable-next-line unicorn/consistent-function-scoping
const selectCounter = (state: Readonly<{ value: number }>): number =>
state.value;
const selectDoubled = createSelector(
selectCounter,
(counter) => counter * 2,
);
const selectQuadrupled = createSelector(
selectDoubled,
(doubled) => doubled * 2,
);
let mut_lastValue = selectQuadrupled(store.getState());
store.subscribe(() => {
mut_lastValue = selectQuadrupled(store.getState());
});
for (let mut_i = 1; mut_i <= n; mut_i++) {
store.dispatch(counterSlice.actions.set(mut_i));
}
return mut_lastValue;
};
export const runBenchmark = (n: number): number => {
const store = createStore<Readonly<{ counter: number }>>()(() => ({
counter: 0,
}));
const selectQuadrupled = (state: Readonly<{ counter: number }>): number =>
state.counter * 2 * 2;
let mut_lastValue = selectQuadrupled(store.getState());
store.subscribe((state) => {
mut_lastValue = selectQuadrupled(state);
});
for (let mut_i = 1; mut_i <= n; mut_i++) {
store.setState({ counter: mut_i });
}
return mut_lastValue;
};
export const runBenchmark = (n: number): number => {
const state = proxy({ counter: 0 });
let mut_lastValue = state.counter * 2 * 2;
const unsubscribe = subscribe(
state,
() => {
mut_lastValue = state.counter * 2 * 2;
},
true,
);
for (let mut_i = 1; mut_i <= n; mut_i++) {
state.counter = mut_i;
}
unsubscribe();
return mut_lastValue;
};

シナリオ: ダイヤモンド依存関係

Section titled “シナリオ: ダイヤモンド依存関係”

複数の派生値が1つにマージされる場合に各ライブラリがどのように処理するかをテストする、ダイヤモンド型の依存関係グラフです:

  • ループ: counter を 11 から NN まで同期的に更新(N=105N = 10^5).
  • 検証: 最終値 =N×5= N \times 5.
  • Zustand は独立した派生値を組み合わせるメカニズムがないため除外。
LibraryMedian (ms)Min (ms)Max (ms)p95 (ms)Ops/sec
SynState19.5417.9325.7922.635,118,062
RxJS9.318.869.689.5710,737,673
MobX99.9098.57101.49101.281,000,966
Jotai550.21543.65560.05557.63181,750
Redux317.42289.68362.50339.75315,041
export const runBenchmark = (n: number): number => {
const [counter, setCounter] = createState(0);
const doubled = counter.pipe(map((x) => x * 2));
const tripled = counter.pipe(map((x) => x * 3));
const sum = combine([doubled, tripled]).pipe(map(([d, t]) => d + t));
let mut_lastValue = 0;
const subscription = sum.subscribe((v) => {
mut_lastValue = v;
});
for (let mut_i = 1; mut_i <= n; mut_i++) {
setCounter(mut_i);
}
subscription.unsubscribe();
return mut_lastValue;
};
export const runBenchmark = (n: number): number => {
const counter = new BehaviorSubject(0);
const doubled = counter.pipe(map((x) => x * 2));
const tripled = counter.pipe(map((x) => x * 3));
const sum = combineLatest([doubled, tripled]).pipe(map(([d, t]) => d + t));
let mut_lastValue = 0;
const subscription = sum.subscribe((v) => {
mut_lastValue = v;
});
for (let mut_i = 1; mut_i <= n; mut_i++) {
counter.next(mut_i);
}
subscription.unsubscribe();
return mut_lastValue;
};
export const runBenchmark = (n: number): number => {
const state = observable({ counter: 0 });
const doubled = computed(() => state.counter * 2);
const tripled = computed(() => state.counter * 3);
const sum = computed(() => doubled.get() + tripled.get());
let mut_lastValue = 0;
const dispose = reaction(
() => sum.get(),
(value) => {
mut_lastValue = value;
},
{ fireImmediately: true },
);
for (let mut_i = 1; mut_i <= n; mut_i++) {
runInAction(() => {
state.counter = mut_i;
});
}
dispose();
return mut_lastValue;
};
export const runBenchmark = (n: number): number => {
const counterAtom = atom(0);
const doubledAtom = atom((get) => get(counterAtom) * 2);
const tripledAtom = atom((get) => get(counterAtom) * 3);
const sumAtom = atom((get) => get(doubledAtom) + get(tripledAtom));
const store = createStore();
let mut_lastValue = store.get(sumAtom);
const unsub = store.sub(sumAtom, () => {
mut_lastValue = store.get(sumAtom);
});
for (let mut_i = 1; mut_i <= n; mut_i++) {
store.set(counterAtom, mut_i);
}
unsub();
return mut_lastValue;
};
export const runBenchmark = (n: number): number => {
const counterSlice = createSlice({
name: 'counter',
initialState: { value: 0 },
reducers: {
set: (state, action: Readonly<{ payload: number }>) => {
state.value = action.payload;
},
},
});
const store = configureStore({
reducer: counterSlice.reducer,
middleware: () => new Tuple(),
});
// eslint-disable-next-line unicorn/consistent-function-scoping
const selectCounter = (state: Readonly<{ value: number }>): number =>
state.value;
const selectDoubled = createSelector(
selectCounter,
(counter) => counter * 2,
);
const selectTripled = createSelector(
selectCounter,
(counter) => counter * 3,
);
const selectSum = createSelector(
selectDoubled,
selectTripled,
(doubled, tripled) => doubled + tripled,
);
let mut_lastValue = selectSum(store.getState());
store.subscribe(() => {
mut_lastValue = selectSum(store.getState());
});
for (let mut_i = 1; mut_i <= n; mut_i++) {
store.dispatch(counterSlice.actions.set(mut_i));
}
return mut_lastValue;
};

シナリオ: ディープチェーンスループット

Section titled “シナリオ: ディープチェーンスループット”

最初の2つのシナリオは固定サイズのグラフを使用しています。このシナリオはスループットのインタラクティブデモと同じトポロジーを使用します: depth-MMscan チェーンで、各ステージが前のステージに向かって lerp し、combineすべての M+1M+1 個の出力を読み取ります。KK 回のソース更新を同期的に実行します(アニメーションフレームあたり KK 回の更新をシミュレート)。

以下の図は M=3M = 3 の場合のグラフ構造です。実際のベンチマークでは MM5050200200 です:

  • KK(計測あたりの更新回数): 100100, 500500, 10001000.
  • MM(チェーンの深さ): 5050, 100100, 200200.
  • Zustand、Valtio、Redux は除外(独立した派生値による scan チェーンを表現する仕組みがないため)。
  • 5,000 ms を超える計測は打ち切り。
LibraryK=100, M=50K=500, M=50K=1000, M=50K=100, M=100K=500, M=100K=1000, M=100K=500, M=200K=1000, M=200
SynState0.4 ms1.0 ms1.5 ms0.3 ms1.6 ms2.8 ms2.9 ms5.6 ms
RxJS3.3 ms14.6 ms31.1 ms14.2 ms66.7 ms135.0 ms335.3 ms678.4 ms
Jotai9.2 ms46.5 ms91.3 ms17.2 ms89.6 ms182.9 ms194.5 ms398.5 ms
MobX18.9 ms79.4 ms156.2 ms30.0 ms150.1 ms289.9 ms289.8 ms575.2 ms
  • SynStateKKMM の両方に対して線形に増加します。K=1000K=1000, M=200M=200 でも 5.6 ms で完了し、プッシュベースの直接関数呼び出しによるステージあたりのオーバーヘッドはごくわずかです。
  • RxJSK=1000K=1000, M=200M=200678 ms — SynState の約121倍です。上で説明した通り、これは定数倍の差ではなく combineLatest の冗長な発行による二次関数的 O(M2)O(M^2) な計算量の違いです。インタラクティブデモでも同じ増大が確認できます。
  • JotaiK=1000K=1000, M=200M=200399 ms — SynState の約71倍です。selectAtom の更新ごとのオーバーヘッド(エポックベースの再計算と動的依存関係追跡)に起因します。
  • MobXK=1000K=1000, M=200M=200575 ms — SynState の約103倍です。このシナリオは scan(ステートフルな累積:各ステージが自身の前回値と新しい入力を必要とする)を使用します。MobX の computed は純粋な派生であり、自身の前回出力にアクセスする手段を持たないため、ここでは使えません。observable.box + reaction(push-based)を使う必要があり、プロキシ追跡・依存通知・autorun の再読み取りによるステージごとのオーバーヘッドが発生します。対照的に、カスケードダイアモンドシナリオはステートレスな派生のため computed の遅延評価で高効率に処理できます。
  • K=500K=500, M=100M=100(60fps アニメーションで中程度のグラフ複雑度を想定)の場合、SynState(1.6 ms)は 16ms のフレーム時間内に余裕で収まりますが、RxJS(67 ms)、Jotai(90 ms)、MobX(150 ms)はいずれも大幅に超過します。

シナリオ: カスケードダイアモンド

Section titled “シナリオ: カスケードダイアモンド”

このシナリオは、RxJS の combineLatest でダイアモンドが直列に連なった場合の指数関数的な増大を実証します。各ステージは前の出力を2つのブランチ(ファンアウト D=2D = 2)に分岐させ、再び結合します。NN 個の二分岐ダイアモンドがチェーンを形成します:

上図は N=3N = 3 の場合です。ベンチマークでは NN222020 です。

  • KK(ソース更新回数): 100100.
  • NN(カスケード深さ): 2,4,6,8,10,12,14,16,18,202, 4, 6, 8, 10, 12, 14, 16, 18, 20.
  • 5,000 ms を超える計測は打ち切り。
LibraryN=2N=4N=6N=8N=10N=12N=14N=16N=18N=20
SynState0.5 ms0.6 ms0.5 ms0.5 ms0.8 ms1.9 ms6.2 ms23.1 ms90.8 ms359.2 ms
RxJS0.2 ms0.5 ms2.1 ms11.1 ms60.2 ms373.9 ms2018.1 ms> 5000 ms> 5000 ms> 5000 ms
Jotai1.5 ms1.9 ms2.5 ms5.3 ms7.2 ms13.0 ms35.9 ms116.4 ms414.5 ms1666.5 ms
MobX1.0 ms0.6 ms0.9 ms0.8 ms0.9 ms0.9 ms1.0 ms1.1 ms1.2 ms1.3 ms
  • RxJS は典型的な O(2N)O(2^N) の指数関数的増大(二分岐)を示します。N=16N=16 でタイムアウト(ソース更新あたり 216=655362^{16} = 65536 回の発行 ×100\times 100 回の更新)。N=14N=14 では既に 2,018 ms — SynState(6.2 ms)の300倍以上です。
  • MobX は遅延 computed 評価により N=20N=20 でも 1.3 ms 以下です。MobX の computed 値は依存が変化しても即座に再計算されず、実際に読み取られたときに初めて再計算されます。reaction は最終段の computed のみを読み取り、それがチェーンを遡って各 computed を1回ずつ遅延評価するため、更新あたりの総作業量は O(N)O(N) で、オーバーヘッドも最小限です。これはディープチェーンスループットシナリオで MobX が SynState の約103倍遅い結果と対照的です。あちらのシナリオでは computed ではなく reaction チェーン(push-based)を使用しており、パフォーマンス特性は使用する MobX プリミティブに大きく依存します。
  • SynState は深さ順走査により O(N)O(N) で伝播しますが、このベンチマークでは各 runBenchmark 呼び出しでグラフを再構築(3N+13N+1 個の Observable を二分探索挿入で propagationOrder に登録)しているため、その構築コストが NN の増加とともに支配的になります。N=20N=20 で 359 ms。グラフを一度構築して繰り返し更新する実アプリケーションでは、更新あたりのコストはディープチェーンスループットシナリオで示した通り O(N)O(N) です。
  • Jotai は派生 atom 数に伴う store.set() ごとの DFS オーバーヘッドの複合により、O(N)O(N) より速く増加します。N=20N=20 で 1,667 ms です。

シナリオ: 条件付きファンアウト

Section titled “シナリオ: 条件付きファンアウト”

これまでのシナリオは伝搬スループットをテストしていました — すべてのソース更新が subscriber に到達します。このシナリオは異なるパターンをテストします: 購読セットに含まれない observable への更新コスト — 動的依存追跡が最も有利に働くケースです。

グラフには B 個の分岐とセレクターがあります。セレクターはベンチマーク中ずっと 00 に固定されており、branch[0] のみが「アクティブ」です。ベンチマークでは branch[1](非アクティブな分岐)を K=105K = 10^5 回更新します。

このシナリオの核心は、ライブラリによって branch[1] の observer リスト(購読者の有無)が異なる 点です:

  • 静的購読(SynState, RxJS)combine が構築時に全分岐を購読するため、branch[1] にも observer が登録されています。branch[1] が更新されるたびに combine が発火し、map が前回と同じ値を生成し、等値比較で下流への伝搬を防止します。結合配列のサイズが増えるため、コストは B に比例します。
  • 動的購読(Jotai, MobX)computed や派生 atom の関数が実行時に .get() で実際にアクセスした observable だけを購読します。selector = 0 のとき branch[0].get() のみが呼ばれるため、branch[1] の observer リストはです。branch[1] を更新しても、通知先が存在しないため何も起きません。
LibraryB=2B=5B=10B=20B=50B=100B=200B=500B=1000
SynState24.2 ms24.2 ms33.7 ms48.1 ms90.8 ms154.0 ms297.4 ms703.3 ms1466.0 ms
RxJS5.6 ms5.3 ms5.1 ms5.4 ms6.1 ms9.2 ms22.3 ms37.2 ms61.4 ms
Jotai69.6 ms69.8 ms73.7 ms68.8 ms72.1 ms69.0 ms70.5 ms72.5 ms69.9 ms
MobX2.3 ms2.5 ms2.4 ms2.4 ms2.4 ms2.4 ms2.4 ms2.4 ms2.7 ms
  • MobX はこのシナリオで最も高速です。computed(() => branches[selector.get()].get()) は実行時に selectorbranches[0] のみを購読するため、branches[1] の observer リストは空です。branches[1] を更新しても通知先が存在せず、B に関係なく約 2.4 ms で一定です。

  • SynState のコストは B に対して線形に増加します。combine が構築時に全分岐を購読するため、どの分岐が更新されても combine が発火します。グラフで線形な傾向が明確に見えます:B=2 で 24ms、B=1000 で 1,466ms(1更新あたり1分岐あたり約 1.5μs)。

  • RxJS は SynState と同じ静的な combineLatest アプローチです。コストは B に応じて増加しますが、SynState より緩やかです。combineLatest は等値比較による下流伝搬スキップを行わず常に発行するため、subscriber は毎回呼ばれますが処理は最小限(値の代入のみ)です。

  • Jotai は MobX と同じ動的購読を行います — resultAtom は非アクティブな分岐を購読していないため再計算はトリガーされません。しかし store.set() 自体が atom store 内で invalidation チェック等の管理処理を実行するため、B に関係なく約 70ms の一定オーバーヘッドが発生します。このため B≤50 では SynState より遅い結果です。

export const runBenchmark = (k: number, branchCount: number): number => {
const [selector] = createState(0);
const branches: DeepReadonly<
{
source: Observable<number>;
set: (v: number) => void;
}[]
> = Arr.zeros(asUint32(branchCount)).map(() => {
const [source, setSource] = createState(0);
return { source, set: setSource };
});
const allSources = [
selector,
...branches.map((b) => b.source),
] as const satisfies NonEmptyArray<Observable<number>>;
const result = combine(allSources).pipe(
map(
([selectorValue, ...branchesValue]) =>
branchesValue[selectorValue] ?? 0,
),
);
let mut_lastValue = 0;
const subscription = result.subscribe((v) => {
mut_lastValue = v;
});
// Update an INACTIVE branch (branch 1, while selector = 0)
const inactiveBranch = branches[1];
if (inactiveBranch === undefined) {
throw new Error('need at least 2 branches');
}
for (let mut_i = 1; mut_i <= k; mut_i++) {
inactiveBranch.set(mut_i);
}
subscription.unsubscribe();
return mut_lastValue;
};
export const runBenchmark = (k: number, branchCount: number): number => {
const selector = new BehaviorSubject(0);
const branches: readonly BehaviorSubject<number>[] = Arr.zeros(
asUint32(branchCount),
).map(() => new BehaviorSubject(0));
const result = combineLatest([selector, ...branches]).pipe(
map((values) => {
const sel = values[0];
return values[sel + 1] ?? 0;
}),
);
let mut_lastValue = 0;
const subscription = result.subscribe((v) => {
mut_lastValue = v;
});
// Update an INACTIVE branch (branch 1, while selector = 0)
const inactiveBranch = branches[1];
if (inactiveBranch === undefined) {
throw new Error('need at least 2 branches');
}
for (let mut_i = 1; mut_i <= k; mut_i++) {
inactiveBranch.next(mut_i);
}
subscription.unsubscribe();
return mut_lastValue;
};
export const runBenchmark = (k: number, branchCount: number): number => {
const selector = observable.box(0);
const branches: readonly ReturnType<typeof observable.box<number>>[] =
Arr.zeros(asUint32(branchCount)).map(() => observable.box(0));
// Dynamic dependency: only tracks the branch selected by selector
const result = computed(() => {
const sel = selector.get();
const target = branches[sel];
if (target === undefined) {
return 0;
}
return target.get();
});
let mut_lastValue = 0;
const dispose = reaction(
() => result.get(),
(value) => {
mut_lastValue = value ?? 0;
},
{ fireImmediately: true },
);
// Update an INACTIVE branch (branch 1, while selector = 0)
const inactiveBranch = branches[1];
if (inactiveBranch === undefined) {
throw new Error('need at least 2 branches');
}
for (let mut_i = 1; mut_i <= k; mut_i++) {
inactiveBranch.set(mut_i);
}
dispose();
return mut_lastValue;
};
export const runBenchmark = (k: number, branchCount: number): number => {
const selectorAtom = atom(0);
const branchAtoms: readonly WritableAtom<
number,
Mutable<readonly [number]>,
void
>[] = Arr.zeros(asUint32(branchCount)).map(() => atom(0));
// Dynamic dependency: only reads the branch selected by selectorAtom
const resultAtom = atom((get) => {
const sel = get(selectorAtom);
const targetAtom = branchAtoms[sel];
if (targetAtom === undefined) {
return 0;
}
return get(targetAtom);
});
const store = createStore();
let mut_lastValue = store.get(resultAtom);
const unsub = store.sub(resultAtom, () => {
mut_lastValue = store.get(resultAtom);
});
// Update an INACTIVE branch (branch 1, while selector = 0)
const inactiveBranchAtom = branchAtoms[1];
if (inactiveBranchAtom === undefined) {
throw new Error('need at least 2 branches');
}
for (let mut_i = 1; mut_i <= k; mut_i++) {
store.set(inactiveBranchAtom, mut_i);
}
unsub();
return mut_lastValue;
};
  • ウォームアップ: 5ラウンド(破棄)。
  • 測定: 20ラウンド。
  • NN: 1ラウンドあたり 10510^5 回の更新。
  • タイミングperformance.now()(サブミリ秒精度)。
  • 統計: 中央値、最小、最大、p95、ops/sec(中央値に基づく)。
  • Redux: デフォルトのミドルウェア(thunk、シリアライザブルチェック、イミュータビリティチェック)は middleware: () => new Tuple() で無効化されています。これらは本番ビルドでは tree-shaking で除去される開発時チェックです。
  • Zustand(派生チェーンのみ): Zustand には独立した派生値プリミティブがないため、派生値はセレクター内でインラインに計算されます(counter * 2 * 2)。同じ理由で、ダイヤモンド依存関係シナリオからも除外されています — 独立した派生値を定義して組み合わせるメカニズムがありません。
  • Valtio(派生チェーンのみ): Zustand と同様に、Valtio のバニラ API には組み込みの派生値プリミティブがないため(derive は別パッケージ)、派生値は subscribe コールバック内でインラインに計算されます(state.counter * 2 * 2)。第3引数の true は同期通知を有効にし、subscriber がすべてのミューテーションで呼び出されるようにします(Valtio はデフォルトで非同期にバッチ処理します)。同じ理由でダイヤモンド依存関係シナリオからも除外されています。
  • RxJS(ダイヤモンド依存関係): combineLatestグリッチ問題により、ソース更新ごとに subscriber を2回発火させます。ベンチマークはこの追加作業を含むウォールクロック時間を測定しており、RxJS におけるダイヤモンド依存関係の実際のコストを反映しています。
  • MobX: 他のライブラリの subscribe-and-record パターンに合わせるため、autorun ではなく reaction を使用しています。MobX の strict mode で要求されるとおり、runInAction で各ミューテーションをラップしています。
  • Jotaistore.sub() はコールバックに現在の値を渡さないため、リスナー内で追加の store.get() 呼び出しが必要です。初期値もsubscribe前に store.get() で読み取られます。
  • すべてのライブラリは、ベンチマーク関数内でステート、派生値、およびsubscriptionを作成します。セットアップコストは測定に含まれています。

Jotai はグリッチフリーのセマンティクスを持ちながら、両方のシナリオで最も遅いライブラリです。根本原因は更新ごとのフレームワークオーバーヘッド、つまりグラフのサイズに関係なく Jotai のストアが store.set() の呼び出しごとに実行する処理量です。

store.set(counterAtom, i) が呼び出されると、Jotai のストアは以下のステップを実行します:

  1. 値の更新 — 新しい値を書き込み、Object.is で比較。O(1)O(1)
  2. 無効化パスinvalidateDependents)— マウントされたすべての依存先の DFS トラバーサル。Map<Atom, EpochNumber> でのエポック番号追跡により各ノードを「再計算が必要」とマーク。O(N)O(N)NN = 影響を受ける atom の数)。
  3. トポロジカルソートrecomputeInvalidatedAtoms)— 訪問追跡に WeakSet を使用した2回目の DFS 後順トラバーサル。正しい順序の再計算リストを生成。O(N)O(N)
  4. 再計算 — トポロジカル順序の各 atom に対して read 関数を実行。read 関数内で、各 get(dep) 呼び出しは: (a) 依存先の AtomState を検索、(b) エポック番号を比較して古さをチェック、(c) 将来の無効化のために Map に依存関係を記録。コストは O(Di)O(\sum D_i) — 再計算されるすべての atom の直接依存の合計であり、積ではない。各1つの依存を持つ N 個の atom のチェーンでは O(N)O(N)
  5. subscriber 通知 — リスナーコールバックを発火。Jotai の store.sub() は新しい値を渡さないため、コールバックは store.get() を再度呼び出す必要があり、追加のキャッシュ検索が発生する。リスナーあたり O(1)O(1) だが、追加のトラバーサルが1回発生。
ステップSynStateJotai
グラフの解決構築時に1回store.set() のたびに(ステップ2-3)
伝搬事前構築されたsubscriptionチェーンを通じた直接関数呼び出しgetter spy + Map/WeakSet 操作を伴う read 関数実行
依存関係の追跡静的(構築時に設定)動的(getter spy を通じて評価のたびに再検出)
鮮度チェック不要(プッシュ型)get() 呼び出しごとのエポック番号比較
subscriber への値の配送値がコールバックに直接渡されるコールバックが値を読むために store.get() を呼び出す必要がある

ベンチマークのループは更新ごとの差を測定可能にするために100,000回の更新を実行しています。しかし、この定数倍のコストは高頻度イベントを扱う実際のアプリケーションでも問題になります。

例えば、mousemovepointermove イベントは1秒間に数十回発火します。各イベントがリアクティブパイプラインを駆動する場合(ドラッグ&ドロップ、ツールチップの位置計算、キャンバスアニメーションなど)、更新ごとのオーバーヘッドがイベント発生頻度に比例して増大します。チェーンの深さが増すと差は劇的になります: スループットデモ(depth-100 チェーン、フレームあたり500回更新)では、Jotai では ms/frame が 16ms を超えて目に見えるカクつきが生じる一方、SynState は16ms以内で安定して描画できます。

更新ごとの差が生じる根本原因:

  • SynStatesetCounter(i)doubled の map 関数を呼び出し → quadrupled の map 関数を呼び出し → subscriber を呼び出し。フレームワークによる管理処理を伴わない、3回の直接関数呼び出しのみ。
  • Jotaistore.set(counterAtom, i) → DFS 無効化(WeakSet アロケーション + Map 書き込み)→ DFS トポロジカルソート(WeakSet アロケーション + 配列 push + reverse)→ doubledAtom の再計算(Map 検索 + エポックチェック + Map 書き込み)→ quadrupledAtom の再計算(同上)→ コールバックをフラッシュ → store.get(quadrupledAtom)(キャッシュ検索)。

100,000回のイテレーションにわたって、SynState は3回の関数呼び出しが処理の大半を占める一方、Jotai では更新ごとの管理処理(Map/WeakSet アロケーション、エポック比較、2回の DFS トラバーサル)が蓄積され、観測される約29倍の差になります。