mcpField
R
reactive-engine

reactive-engine

github.com/virtuoso-dev開発144 回インストール2 日前に更新
リポジトリ

reactive-engine について

reactive-engine は、アプリケーションや再利用可能なライブラリを開発する人向けの、型付きノードによるリアクティブ状態システムです。エンジン単位の状態に、React 連携、データ取得、ルーティング、永続化、診断機能を組み合わせています。

主な機能

  1. 01型付きリアクティブノードとエンジン単位の状態
  2. 02グラフ配線、オペレーター、トランザクション
  3. 03React フックとリモートエンジンアクセス
  4. 04Query と Mutation によるデータ取得
  5. 05ルーティングとブラウザー履歴の同期
  6. 06ストレージ永続化と伝播診断

活用シーン

  1. 01アプリケーションの状態とイベントフローのモデル化
  2. 02リアクティブ状態と React コンポーネントの接続
  3. 03グラフ配線によるデータ取得と再取得の実行
  4. 04Cell の永続化と伝播問題の調査

インストール

コマンドをコピーしてターミナルで実行します。

$npx skills add https://github.com/virtuoso-dev/skills --skill reactive-engine

インストール後、Claude Code で/reactive-engineを入力して読み込まれたか確認します。

同梱ファイル

SKILL.md と一緒にインストールされる参考資料やスクリプト。

references/core/
01.core-concepts.md
02.engine-and-lifecycle.md
03.transactions.md
04.operators-and-combinators.md
05.building-on-the-engine.md
06.diagnostics.md
README.md
references/query/
01.queries-and-mutations.md
README.md
references/react/
01.react-integration.md
README.md
references/router/
01.routing.md
README.md
references/storage/
01.storage-links.md
README.md

詳細

Reactive Engine

型付きノードのグラフを基盤とするリアクティブな状態システムです。次の5つのパッケージで構成されています。

パッケージ提供内容
@virtuoso.dev/reactive-engine-coreノード(Cell、Stream、Trigger、Resource、DerivedCell)、Engine、オペレーター、コンビネーター、e 名前空間
@virtuoso.dev/reactive-engine-reactEngineProvider、状態フック、ローカルおよびリモートの診断フック
@virtuoso.dev/reactive-engine-queryノード上に構築されたデータ取得用の Query と Mutation
@virtuoso.dev/reactive-engine-routerRoute、Layout、Guard、Router — ルートはノードです
@virtuoso.dev/reactive-engine-storagelinkCellToStorage(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 でテストできます

診断で伝播の問題をトラブルシューティングする

値が欠落する、重複する、予期せず抑制される、誤ったエンジンを経由する、または同期伝播中に例外が発生する場合は、診断を使用します。

  1. describeNode でルート、想定されるシンク、中間ノードにラベルを付けます。組み込みの上限付きサマリーでは説明できないアプリケーション固有の値に限り、対象を絞った summarize 関数を追加します。
  2. 適切なライフサイクルにアタッチします。EngineProvider.diagnostics は initFn を含みます。useEngineDiagnostics はコミット後に最も近いエンジンを監視します。useRemoteEngineDiagnostics は兄弟ツール向けで、エンジンの可用性に応じて動作します。React Strict Mode では初期化が複数回評価されることがあるため、開発時のプロバイダー初期化レコードは少なくとも1回記録されるものとして扱います。
  3. まず値のキャプチャを無効にして開始します。ルートとイベントを順番に確認します。distinct-suppressed、no-candidate、プルーンイベント、error.phase を調べてから、captureValues: 'summary' を有効にします。
  4. 状態が誤ったインスタンスから来ているように見える場合は、engineInstanceId と engineLabel を比較します。同期的な入れ子伝播や親子間の伝播には、transactionId、origin、parentCycle を使用します。非同期処理は新しいトランザクションを開始するため、アプリケーションレベルの相関付けが必要です。
  5. 仮説の検証に必要なノードに限り、サマリーを有効にします。エクスポートする前に機密情報を除去します。ノードのサマライザーには値がそのまま渡されるため、値を変更したり、副作用を発生させたりしてはいけません。
  6. 再現後はオブザーバーを削除します。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/にあります。

@virtuoso.dev/reactive-engine-coreのエクスポートされたシンボルに関するJSDocは充実しています。シグネチャに迷った場合は、パッケージに同梱されている型定義を確認してください。

コメント