Skip to content

9.8. React Integration

@thekaveh/vmx-react is the official React 18 and React 19 adapter for @thekaveh/vmx. It keeps React out of the language-neutral core while giving applications a StrictMode-safe, server-rendering-safe external-store boundary. The source package is complete; public npm publication follows the first public core package tracked by issue #57.

9.8.1. Reactivity primitive

The adapter uses React's useSyncExternalStore contract. A VmxStore owns one lazy subscription to a VMx hub, exposes stable subscribe and cached monotonic snapshot functions, and coalesces one synchronous hub drain into one React invalidation. Subscribe-time catch-up closes mutations made while disconnected or between render and commit. useVmx applies a selector with Object.is by default or an explicit equality function such as shallowEqual.

9.8.2. Mapping

React binding VMx source
createVmxStore / useVmx one shared IMessageHub selector boundary
useVm sender-filtered component or form VM updates
useCommand ICommand.canExecuteChanged and stable execution
useObservableList / useVmCollection identity-preserving collection snapshots
useDerivedProperty pushed derived values, including unseeded state
useAsyncResource discriminated async-resource state

9.8.3. Install and create the shared store

npm install @thekaveh/vmx @thekaveh/vmx-react react rxjs use-sync-external-store

Until publication, follow the pack-then-install source procedure; do not link a live adapter checkout into a React 18 application. Create one store at the application composition root:

import { createVmxStore, shallowEqual, useVmx } from "@thekaveh/vmx-react";

const store = createVmxStore(app.hub);

function Summary() {
  const summary = useVmx(
    store,
    () => ({ title: app.model.title, busy: app.busy }),
    shallowEqual,
  );
  return <p>{summary.title}{summary.busy ? "…" : ""}</p>;
}

Call store.dispose() only when the owning application scope is permanently destroyed. React mount cleanup is managed by the hooks.

9.8.4. Focused bindings and conditional VMs

Use the narrowest binding that represents what the component renders:

const title = useVm(noteVm, vm => vm.model.title);
const save = useCommand(noteVm.saveCommand);
const notes = useVmCollection(workspace.notes);
const total = useDerivedProperty(workspace.total);
const resource = useAsyncResource(screen.data);

useCommand accepts an ICommand separately from its owning VM. Invoke the stable callback returned by the binding; the equivalent direct VMx intent is saveCommand.execute().

Never call a hook conditionally or pass null. Mount a child which owns one unconditional binding when a VM is optional:

function MaybeEditor({ vm }: { vm: EditorVM | null }) {
  return vm === null ? <EmptyEditor /> : <BoundEditor vm={vm} />;
}
function BoundEditor({ vm }: { vm: EditorVM }) {
  const live = useVm(vm);
  return <Editor title={live.model.title} />;
}

The same useVm contract applies to FormVM; a shared-store selector is useful when a component combines form state with other application state.

9.8.5. StrictMode, SSR, hydration, and virtualization

The store connects on the first listener and disconnects on the last, including React StrictMode's development remount. Create a store per server request, render and hydrate from the same VM snapshot, and never share mutable server stores between requests. getServerSnapshot does not subscribe during server rendering.

Virtualized lists should bind once with useVmCollection or useObservableList, use stable model keys, and pass item identities into rows. Rows that display mutable VM state may call useVm locally.

9.8.6. Flagship and release policy

examples/typescript/react/notes-showcase/ is the Notes Workspace React 19 flagship. It consumes the official package while keeping only application-specific browser adapters in the example.

The adapter follows independent SemVer under react-v* tags. Core releases do not force adapter releases unless its public API or compatibility range changes. See packages/react/RELEASING.md.

9.8.7. Serialize Portal Dialog Requests

Keep portal overlay state in an external observable store just like VM state, but do not model it as one replaceable request slot. The flagship ReactDialogService retains one active request and a FIFO queue. Each modal resolver settles only its own promise and publishes the next request (or null) to useSyncExternalStore. Explicit close uses the operation's neutral result (false, null, or completion) and advances the same queue.

This is the queueing policy permitted by DIA-006. It prevents a second confirm, file picker, or notification from replacing the active request and leaving the first caller pending forever. See ReactDialogService.tsx and its two-call settlement tests.