9.10. Svelte Integration¶
Wire a ComponentVMOf<M> to a Svelte component through a custom store, which
works on Svelte 4 and 5, or through Svelte 5 runes ($props, $derived,
$effect).
9.10.1. Reactivity primitive¶
- Store: any object with a
subscribe(run)method that returns an unsubscribe function is a store, and$storein a component subscribes to it for the component's lifetime.readable(initial, start)runsstartfor the first subscriber and its cleanup after the last, and keeps the last value in between. - Runes (Svelte 5):
$derivedrecomputes when the state it reads changes, and an$effectre-runs, after its returned cleanup, when the state it reads changes. Cleanup also runs when the component is destroyed.
9.10.2. Mapping¶
| Svelte | VMx |
|---|---|
store set(...) / $derived override |
PropertyChangedMessage<T> handler |
on:click / onclick |
command.execute() in the handler |
derived(...) / $derived(...) |
DerivedProperty<T> / fromSources |
store stop function / $effect return |
dispose the hub subscription |
The view owns only its hub subscription. Whoever created the VM constructs and
disposes it; neither adapter changes the VM's lifecycle. Both adapters observe
properties announced with PropertyChangedMessage. Lifecycle status arrives as
ConstructionStatusChangedMessage and needs its own filter.
republishModel() announces a model that was mutated in place, so the
announced value is the same object the view already holds. A store's set
always notifies for objects, and the runes component wraps each value in a new
object, so both render the mutation.
9.10.3. Adapter skeleton — store (Svelte 4 and 5)¶
The store and component below are the exact files run by
examples/typescript/integration-recipes against svelte 5.57.1. The store
uses only the svelte/store contract that Svelte 4 shares.
// vmStore.ts
import { readable, type Readable } from "svelte/store";
import { filter } from "rxjs";
import { type ComponentVMOf, type IMessageHub, PropertyChangedMessage } from "@thekaveh/vmx";
/**
* A store for one VM property. Svelte runs the start function for the first
* subscriber and its cleanup after the last, so every subscriber shares one
* hub subscription, and each (re)connection re-reads the VM to pick up
* changes made while the store had no subscribers.
*/
export function vmStore<M, K extends keyof ComponentVMOf<M>>(
vm: ComponentVMOf<M>,
hub: IMessageHub,
property: K,
): Readable<ComponentVMOf<M>[K]> {
return readable(vm[property], (set) => {
const subscription = hub.messages
.pipe(
filter(
(m): m is PropertyChangedMessage<unknown> =>
m instanceof PropertyChangedMessage && m.sender === vm && m.propertyName === property,
),
)
.subscribe(() => set(vm[property]));
try {
// Read after subscribing, so a change in between is not lost.
set(vm[property]);
} catch (error) {
subscription.unsubscribe();
throw error;
}
return () => subscription.unsubscribe();
});
}
<script lang="ts">
import type { ComponentVMOf, ICommand, IMessageHub } from "@thekaveh/vmx";
import type { Note } from "./note";
import { vmStore } from "./vmStore";
export let vm: ComponentVMOf<Note>;
export let hub: IMessageHub;
export let saveCommand: ICommand;
// A new vm or hub creates a new store; `$model` moves to it and releases
// the old one.
$: model = vmStore(vm, hub, "model");
</script>
<h1>{$model.title}</h1>
<button on:click={() => saveCommand.execute()}>Save</button>
Attachment order matters. start subscribes to the hub first and then reads
the VM, so a change made at any point before or during attachment is delivered
with the first value. All subscribers share the one hub subscription; the last
unsubscribe releases it. Because Svelte keeps the old value while nobody is
subscribed, the next subscriber gets a fresh read instead of that stale value.
9.10.4. Adapter skeleton — Svelte 5 runes¶
The same fixture mounts this component with changing inputs:
<script lang="ts">
import { filter } from "rxjs";
import {
PropertyChangedMessage,
type ComponentVMOf,
type ICommand,
type IMessageHub,
} from "@thekaveh/vmx";
import type { Note } from "./note";
interface Props {
vm: ComponentVMOf<Note>;
hub: IMessageHub;
saveCommand: ICommand;
}
const { vm, hub, saveCommand }: Props = $props();
// A fresh box per update: a republished model keeps its reference.
let note = $derived({ model: vm.model });
$effect(() => {
// Reading `vm` and `hub` here re-runs the effect, after its cleanup,
// whenever either input changes.
const source = vm;
const subscription = hub.messages
.pipe(
filter(
(m) =>
m instanceof PropertyChangedMessage &&
m.sender === source &&
m.propertyName === "model",
),
)
.subscribe(() => {
note = { model: source.model };
});
// Catch up with changes made between the render and the subscription.
note = { model: source.model };
return () => subscription.unsubscribe();
});
</script>
<h1>{note.model.title}</h1>
<button onclick={() => saveCommand.execute()}>Save</button>
note recomputes whenever vm changes, and the effect overrides it on each
matching message. The effect reads vm and hub, so a
new VM or hub runs the cleanup, which unsubscribes from the old hub, and then
subscribes to the new one. Its catch-up assignment covers changes made between
the render and the subscription.
9.10.5. Failures and disposed sources¶
- Setup failure: if the hub stream cannot be subscribed, or the store's
catch-up read throws, the error propagates out of
subscribe(the store) or out of mounting (both components). The store releases a hub subscription it has already made before rethrowing, so no listener remains. After a setup failure, discard the store: Svelte keeps the failed subscriber registered and will not callstartagain. - Hub error: the adapters do not handle errors from
hub.messages. RxJS ends the subscription and reports the error throughconfig.onUnhandledError(by default it is rethrown asynchronously). The view keeps its last value; the store subscribes again on its next connection. - Disposed hub:
MessageHub.dispose()completesmessages, which ends the subscription. The view keeps its last value. - Disposed VM: a disposed VM publishes nothing and ignores assignments. The view keeps showing the retained model, and a reconnecting store reads that model. Destroying the component still releases the subscription.
9.10.6. Fuller example¶
No worked Svelte Notes-Showcase ships yet. The React and Vue recipes share the same hub-subscription shape.