reactive-engine について
reactive-engine は、アプリケーションや再利用可能なライブラリを開発する人向けの、型付きノードによるリアクティブ状態システムです。エンジン単位の状態に、React 連携、データ取得、ルーティング、永続化、診断機能を組み合わせています。
主な機能
- 01型付きリアクティブノードとエンジン単位の状態
- 02グラフ配線、オペレーター、トランザクション
- 03React フックとリモートエンジンアクセス
- 04Query と Mutation によるデータ取得
- 05ルーティングとブラウザー履歴の同期
- 06ストレージ永続化と伝播診断
活用シーン
- 01アプリケーションの状態とイベントフローのモデル化
- 02リアクティブ状態と React コンポーネントの接続
- 03グラフ配線によるデータ取得と再取得の実行
- 04Cell の永続化と伝播問題の調査
インストール
コマンドをコピーしてターミナルで実行します。
npx skills add https:/ / github.com/ virtuoso-dev/ skills --skill reactive-engineインストール後、Claude Code で/reactive-engineを入力して読み込まれたか確認します。
同梱ファイル
SKILL.md と一緒にインストールされる参考資料やスクリプト。
references/core/01.core-concepts.md02.engine-and-lifecycle.md03.transactions.md04.operators-and-combinators.md05.building-on-the-engine.md06.diagnostics.mdREADME.mdreferences/query/01.queries-and-mutations.mdREADME.mdreferences/react/01.react-integration.mdREADME.mdreferences/router/01.routing.mdREADME.mdreferences/storage/01.storage-links.mdREADME.md詳細
Reactive Engine
型付きノードのグラフを基盤とするリアクティブな状態システムです。次の5つのパッケージで構成されています。
| パッケージ | 提供内容 |
|---|---|
@virtuoso.dev/reactive-engine-core | ノード(Cell、Stream、Trigger、Resource、DerivedCell)、Engine、オペレーター、コンビネーター、e 名前空間 |
@virtuoso.dev/reactive-engine-react | EngineProvider、状態フック、ローカルおよびリモートの診断フック |
@virtuoso.dev/reactive-engine-query | ノード上に構築されたデータ取得用の Query と Mutation |
@virtuoso.dev/reactive-engine-router | Route、Layout、Guard、Router — ルートはノードです |
@virtuoso.dev/reactive-engine-storage | linkCellToStorage(localStorage / sessionStorage / cookie) |
内部では @virtuoso.dev/data-table もこの仕組みを利用しています。テーブルをリモート制御する場合にも同じ概念が適用されます。また、エンジンを基盤に構築する際のリファレンスとして、data-table のアーキテクチャを参照してください(building-on-the-engine)。
メンタルモデル:ノードは定義であり、状態はエンジンが保持する
ノードコンストラクターは、モジュールスコープで $ サフィックスを付けて定義する、状態を持たない参照(実行時にはシンボル)を返します。ノード自体は状態を保持しません。Engine インスタンスは、ノードが初めて使用されたときに遅延して有効化し、その値を所有します。同じノードを使用する2つのエンジンは、それぞれ独立した状態を持ちます。これにより、エンジンベースのライブラリを再利用できます。つまり、モジュールスコープに1つのグラフを定義し、コンポーネントのインスタンスごとに1つのエンジンを使います。
import { Cell, Stream, Engine, e } from '@virtuoso.dev/reactive-engine-core'
const count$ = Cell(0)
const engine = new Engine()
engine.sub(count$, (value) => console.log('count:', value))
engine.pub(count$, 1) // logs 'count: 1'
ノードの種類 — 状態の特性に応じて選びます。
| ノード | 状態 | 用途 |
|---|---|---|
Cell(initial, distinct?) | 状態を持ち、現在値がある | アプリの状態、設定 |
DerivedCell(initial, source$) | 状態を持ち、ソースを追跡する | 読み取り専用の計算済み状態 |
Stream<T>(distinct?) | 状態を持たず、値を発行する | イベント、コマンド |
Trigger() | 状態を持たず、値もない | 「何かが起きた」ことを示すシグナル(再取得、リセット) |
Resource(factory) | エンジンごとにファクトリーで初期化される | セットアップ/破棄が必要なオブジェクト |
ノードはデフォルトで参照の等価性(===)を使って重複を排除します。同じ参照(または等しいプリミティブ値)を発行しても、再発行されません。更新時には必ず新しい参照を発行してください(new Map(old)、スプレッド構文)。常に発行するには false を渡すか、比較関数 (prev, next) => boolean を渡します(true = 等しい = 抑制)。初回の発行では prev は undefined です。
3 種類の API(最も重要な違い)
同じ動詞には 3 種類の形式があります。誤った形式を使うことが、エラーの主な原因です。
| 形式 | 例 | 使用場所 | 効果 |
|---|---|---|---|
| モジュールスコープのコンビネーター | link, pipe, sub, changeWith, combine / e.* | どこでも(モジュールのトップレベルを含む) | 接続を遅延し、ノードがアクティブになるたびにエンジンごとに 1 回適用します |
| エンジンインスタンスのメソッド | engine.pub, engine.sub, engine.getValue | エンジンを保持している場所 | そのエンジンに対して即座に実行します |
| コンテキストに紐づくユーティリティ | standalone pub, getValue, pubIn / e.pub, ... | サブスクリプションのコールバックとノードの初期化内のみ | 現在実行中のエンジンに対して動作します |
コンテキストに紐づくユーティリティをそれ以外の場所で呼び出すと、No active engine found. You can use getValue only in the context of node subscription callbacks. がスローされます。engine.* メソッドや React フックを使うか、コードをサブスクリプション内に移動して解決してください。非対称な点に注意してください。standalone の sub/link/changeWith はモジュールスコープでも安全に使えます(遅延実行されるため)。一方、standalone の pub/getValue は安全ではありません。
e エクスポートは、すべてのコンビネーター、オペレーター、コンテキストユーティリティを 1 つの名前空間にまとめたものです。配線ブロックで慣用的に使われます。
e.link(
e.pipe(
toggleTheme$,
e.withLatestFrom(theme$),
e.map(([, t]) => (t === 'light' ? 'dark' : 'light'))
),
theme$
)
グラフの配線
link(source$, sink$)— エミッションを転送します。pipe(source$, ...ops)— 新しいノードを生成しますcombine(a$, b$, ...)— いずれかのソースがエミットすると[a, b, ...]をエミットします。withLatestFrom(...)(オペレーター)— 他のノードの値を受動的に読み取り、ソースがエミットしたときだけエミットしますchangeWith(cell$, source$, (cell, value) => next)— ストリームからセルをリデューサー形式で更新します(新しい参照を返します)sub、subMultiple、singletonSub(前のシングルトンサブスクリプションを置き換えます。React のレンダーに応じて実行されるコールバックに使います)- オペレーター:
map(fn, distinct?)、filter(型ガードに対応)、mapTo、scan、once、throttleTime、debounceTime、delayWithMicrotask、onNext、handlePromise
パブリッシュはトランザクションとして実行されます。engine.pubIn({ [a$]: 1, [b$]: 2 }) は 1 つのサイクルにまとめられます。グラフはトポロジカル順に実行されるため、ダイヤモンド型の依存関係では、入力の一貫性を保ちながら下流に正確に 1 回エミットされます。エミッションは同期的です。エラー/完了チャネルはありません(例外が発生するとサイクルが中断されるため、エッジでエラーを処理してください)。サブスクライブしても現在の値は再生されません。必要に応じて getValue でセルを読み取ってください。トランザクションを参照してください。
React 連携
import { EngineProvider, useCellValue, usePublisher } from '@virtuoso.dev/reactive-engine-react'
const Counter = () => {
const count = useCellValue(count$)
const setCount = usePublisher(count$)
return <button onClick={() => setCount(count + 1)}>{count}</button>
}
useCellValue(cell$)はサブスクライブして再レンダーします。useCellValues(a$, b$, ...)は単一の結合サブスクリプションでタプルを返します(一緒に変化するセルにはこちらを推奨します)。useCellはuseStateと同じ形式です。usePublisher(node$)は安定したパブリッシュ関数を返しますEngineProviderのプロパティ:initFn(engine)(1 回限りのセットアップ。ノードの登録、pubInによる初期値の設定、ブリッジの接続を行います)、initWith(セルの初期値)、updateFn/updateDeps(変更されたプロパティを再パブリッシュします。コールバックプロパティにはsingletonSubを使い、再レンダー時に積み重ならず置き換わるようにします)、engineId、engineRef、diagnostics(initFnの前にアタッチします)- リモートフック(
useRemoteCellValue、useRemotePublisher、useRemoteCell、useRemoteCellValues、useRemoteEngineDiagnostics)は、useEngineRef()または文字列のengineIdを使って別のプロバイダーのエンジンにアクセスします。値を返すフックは、そのエンジンがマウントされるまでundefinedを返します。診断情報の監視は、エンジンを待機して自動的にアタッチします - コンポーネントは投影のみにとどめます。セルを読み取り、アクションをパブリッシュします。ロジックはモジュールスコープの配線に置き、React なしの単独の
Engineでテストできます
診断で伝播の問題をトラブルシューティングする
値が欠落する、重複する、予期せず抑制される、誤ったエンジンを経由する、または同期伝播中に例外が発生する場合は、診断を使用します。
describeNodeでルート、想定されるシンク、中間ノードにラベルを付けます。組み込みの上限付きサマリーでは説明できないアプリケーション固有の値に限り、対象を絞ったsummarize関数を追加します。- 適切なライフサイクルにアタッチします。
EngineProvider.diagnosticsはinitFnを含みます。useEngineDiagnosticsはコミット後に最も近いエンジンを監視します。useRemoteEngineDiagnosticsは兄弟ツール向けで、エンジンの可用性に応じて動作します。React Strict Mode では初期化が複数回評価されることがあるため、開発時のプロバイダー初期化レコードは少なくとも1回記録されるものとして扱います。 - まず値のキャプチャを無効にして開始します。ルートとイベントを順番に確認します。
distinct-suppressed、no-candidate、プルーンイベント、error.phaseを調べてから、captureValues: 'summary'を有効にします。 - 状態が誤ったインスタンスから来ているように見える場合は、
engineInstanceIdとengineLabelを比較します。同期的な入れ子伝播や親子間の伝播には、transactionId、origin、parentCycleを使用します。非同期処理は新しいトランザクションを開始するため、アプリケーションレベルの相関付けが必要です。 - 仮説の検証に必要なノードに限り、サマリーを有効にします。エクスポートする前に機密情報を除去します。ノードのサマライザーには値がそのまま渡されるため、値を変更したり、副作用を発生させたりしてはいけません。
- 再現後はオブザーバーを削除します。Core と React は履歴を保持しません。診断 UI を作成する場合は、
EngineProviderの状態ではなく、サイズを制限した外部ストアを使用します。
レコードモデル、安全ルール、証拠と原因の対応表については、伝播の診断を参照してください。プロバイダー、ローカルフック、リモートフック、Strict Mode、SSR のタイミングについては、React 統合を参照してください。
データ取得(reactive-engine-query)
export const tasksQuery = Query<{ listId: string }, Task[]>({
queryFn: async ({ listId }, signal) => {
const res = await fetch(`/api/tasks?listId=${listId}`, { signal })
if (!res.ok) throw new Error('Failed to fetch tasks')
return res.json()
},
initialParams: { listId: '' },
})
Query はノードを返します。data$({ type: 'pending' | 'success' | 'error', data, error, isLoading, isFetching, ... } を持つセル)、params$(新しいパラメーターで再取得するために値を発行)、refetch$、invalidate$(古いデータを表示したまま isFetching: true にし、再実行)、unload$(中止してクリア)、enabled$ です。クエリは有効化時とパラメーター変更時に自動実行されます。実行のたびに、前回の実行は signal を介して中止されます。オプションは retry(デフォルトは3、指数バックオフ)、refetchInterval、initialData、enabled です。
Mutation({ mutationFn, onSuccess?, onError? }) は data$、mutate$、reset$ を返します。ミューテーションは自動実行されません。パラメーターを mutate$ に発行してください。ミューテーションをクエリに再取得させる場合は、コンポーネント内ではなくグラフ内で接続します。
e.link(
e.pipe(
e.merge(createTask.data$, deleteTask.data$),
e.filter((r) => r.type === 'success'),
e.map(() => undefined, false) // distinct=false so consecutive successes each fire
),
tasksQuery.refetch$
)
ルーティング(reactive-engine-router)
export const user$ = Route('/users/{id:number}', UserPage) // params typed from the path
const root = Layout('/', ({ children }) => <main>{children}</main>)
<Router routes={[user$]} layouts={[root]} guards={[authGuard]} />
ルートノードは、一致した場合に型付きパラメーターを出力し、それ以外の場合は null を出力します。ルートノードにパラメーターを発行して移動します(usePublisher(user$)({ id: 42 }))。ルートパラメーターをクエリパラメーターにパイプして、ナビゲーション時にデータを取得します。Guard(pattern, fn) は一致するナビゲーションで実行されます。そのコンテキストでは continue()、navigate()、redirect() を使用できます。非同期ガードでは、ルートが Suspense 内にレンダリングされます。レイアウトは URL プレフィックスで一致し、詳細度に応じてネストされます。LayoutSlotPortal/LayoutSlot/LayoutSlotFill を使うと、ページからレイアウト領域にコンテンツを挿入できます。Router はデフォルトでブラウザーの履歴と同期します。
永続化(reactive-engine-storage)
linkCellToStorage(theme$, { storageType: 'localStorage', key: 'app-theme', debounceMs: 300 })
storageType: 'localStorage' | 'sessionStorage' | 'cookie'(cookie では cookieOptions も追加します)。読み込みは遅延実行され、エンジンごとに、エンジンがセルを初めて有効化したときに一度だけ行われます。書き込みはデバウンスされます。localStorage のリンクはタブ間で同期されます。エンジンの id はキーの名前空間として使用されるため(my-app:app-theme)、複数のインスタンスでそれぞれ独立して永続化できます。SSR に対応しており、window がない場合は何もしません。
エンジン上にコンポーネントまたはライブラリを構築する
データテーブルのアーキテクチャに従います(すべてのパターン)。
- モジュールスコープのノードグラフと、コンポーネントインスタンスごとに1つのエンジン(
EngineProvider経由) - 公開 API は、入力がアクションストリーム(
setX$、resetX$)、出力が状態セル(xState$、多くの場合DerivedCell)です。配線は内部に留めます。 initFn/updateFnのpubIn経由でプロパティをセルに渡し、コールバックプロパティにはsingletonSubを使用します。- オプション機能は個別のモジュールとして用意します(サブパスエクスポート)。コアノードに配線を追加します。初期化は遅延されるため、使用しない機能のコストはかかりません。
engineRef/engineIdと、エクスポートされたノードに対するリモートフックを使って外部から制御します。- 双方向にサブスクライブする関数で命令型のサブシステムをブリッジし、そのクリーンアップが
engine.onDisposeに到達するようにします。
よくある間違い
- サブスクリプションコールバックの外で、スタンドアロンの
pub/getValue/pubInを呼び出すこと。engine.*メソッドまたはReactフックを使用してください。 - 同じオブジェクトを変更して再パブリッシュすること。
distinctは参照の等価性を使うため、何も発行されません。新しい参照をパブリッシュしてください。 - ノード自体が状態を保持すると期待すること。値はエンジン内に存在します。同じ
Cellでも、2つのエンジンでは値が異なります。 - 現在の値を伴って
Streamのサブスクリプションが発火すると期待すること。ストリームはステートレスです。値を持つのはCellだけです。 distinct: falseを指定せずにmap経由でトリガーにパイプすること。連続する同一のマップ済み値はフィルタリングされます。- パラメーターを変更して
Mutationをトリガーすること。ミューテーションは、パラメーターがmutate$にパブリッシュされた場合にのみ実行されます。 - Reactの再レンダリングで再実行されるコード内で通常の
subを使うこと。サブスクリプションが蓄積されるため、singletonSubを使用してください。 - グラフ内にサイクルを作ること。サイクルがあるとハングまたはループするため、アクションストリームまたは
delayWithMicrotaskを使って一方向を切断してください。
参考資料
パッケージごとのガイドはreferences/にあります。
references/core/— core-concepts(ノードの種類、distinct、e名前空間)、engine-and-lifecycle(アクティベーション、APIの種類、子エンジン、破棄)、transactions(サイクル、トポロジカル実行、RxJSとの違い)、operators-and-combinators(完全なリファレンス)、building-on-the-engine(データテーブルに基づくアーキテクチャパターン)、propagation-diagnostics(記録、安全性、トラブルシューティング)、READMEreferences/react/— react-integration(EngineProviderのライフサイクル、フック、リモートフック、SSR)、READMEreferences/query/— queries-and-mutations(結果の状態、ライフサイクル、配線パターン)、READMEreferences/router/— routing(パス構文、ナビゲーション、レイアウト、ガード)、READMEreferences/storage/— storage-links(タイミング、名前空間、タブ間同期)、README
@virtuoso.dev/reactive-engine-coreのエクスポートされたシンボルに関するJSDocは充実しています。シグネチャに迷った場合は、パッケージに同梱されている型定義を確認してください。