MVVM in Solid
How Web Loom ViewModels connect to SolidJS using a useSignal bridge from @web-loom/signals-core into createSignal, then Show, For, and createMemo in the view — demonstrated by apps/mvvm-solid.
MVVM in Solid
Demo:
apps/mvvm-solidis the Solid twin ofapps/mvvm-react: the same greenhouse dashboard and the same@repo/view-models, with a Solid View.
SolidJS uses fine-grained reactivity. Unlike React, Solid does not diff a virtual DOM or re-run component functions on state change. Instead, it creates reactive computation graphs at compile time — when a signal's value changes, only the specific DOM nodes reading that signal update.
Web Loom ViewModels integrate well with Solid because both systems share a similar philosophy: @web-loom/signals-core and SolidJS signals are both synchronous, pull-based, fine-grained reactive primitives. They are not the same graph. The ViewModels stay framework-agnostic (they never import solid-js). A small useSignal hook is the boundary.
Two signal systems
| Layer | Primitive | Role |
| --- | --- | --- |
| Model / ViewModel / Command | @web-loom/signals-core | Shared with React, Vue, Angular, Lit, Vanilla |
| View | Solid createSignal / createMemo / JSX | Fine-grained DOM updates; component functions run once |
| Bridge | useSignal(sig) | observe() copies current + later values into a Solid accessor |
Calling loomSig.get() inside Solid createMemo or JSX does not subscribe Solid to loom updates. Loom tracking only works inside loom computed/effect contexts. That is why the bridge exists.
Solid's Reactivity Primitives
import { createSignal, createEffect, createMemo, onCleanup } from 'solid-js';
const [count, setCount] = createSignal(0); // reactive value
const doubled = createMemo(() => count() * 2); // derived value
createEffect(() => {
console.log('count changed:', count()); // re-runs when count changes
});Signals are synchronous and pull-based — you call count() to read the value. This is nearly identical in spirit to @web-loom/signals-core's signal()/.get().
The useSignal Bridge
apps/mvvm-solid/src/hooks/useSignal.ts (and the create-web-loom Solid template) mirror a Web Loom signal into a Solid signal. observe() seeds the current value immediately — subscribe() alone would skip it — then syncs every later change. onCleanup unsubscribes when the owning component is disposed.
import { createSignal, onCleanup, type Accessor } from 'solid-js';
import { observe, type ReadonlySignal } from '@web-loom/signals-core';
export function useSignal<T>(source: ReadonlySignal<T>): Accessor<T> {
const [value, setValue] = createSignal(source.peek());
const stop = observe(source, (next) => {
setValue(() => next); // thunk so arrays are values, not Solid updaters
});
onCleanup(stop);
return value;
}import { For, Show, onMount } from 'solid-js';
import { greenHouseViewModel } from '@repo/view-models/GreenHouseViewModel';
import { useSignal } from '../hooks/useSignal';
export function GreenhouseList() {
const greenhouses = useSignal(greenHouseViewModel.data$);
const isLoading = useSignal(greenHouseViewModel.isLoading$);
onMount(() => {
void greenHouseViewModel.fetchCommand.execute();
});
return (
<Show when={!isLoading()} fallback={<p>Loading…</p>}>
<ul>
<For each={greenhouses() ?? []}>
{(gh) => (
<li>
{gh.name}
<button onClick={() => greenHouseViewModel.deleteCommand.execute(gh.id!)}>
Delete
</button>
</li>
)}
</For>
</ul>
</Show>
);
}setValue(() => next) is required when next is an array or object: Solid treats a function passed to the setter as an updater.
What stays in Solid
View-only state does not belong on the ViewModel. In GreenhouseList, draft form fields (name, location, size, cropType, editingId) are native Solid signals so typing a letter updates one input. The dashboard combines four bridged loading flags with a Solid createMemo. Lists use <For> so inserting a greenhouse patches one <li>, not the page.
Commands stay on the ViewModel:
import { useSignal } from '../hooks/useSignal';
export function RefreshButton() {
const isExecuting = useSignal(greenHouseViewModel.fetchCommand.isExecuting$);
return (
<button
onClick={() => greenHouseViewModel.fetchCommand.execute()}
disabled={isExecuting()}
>
{isExecuting() ? 'Loading…' : 'Refresh'}
</button>
);
}A Note on Solid's from()
Solid ships a built-in from() helper that converts any object with a subscribe(fn) method into a Solid signal — tempting to reach for here since Web Loom signals already have that shape. It's not a drop-in fit: from() seeds its Solid signal with undefined and waits for the first callback invocation, but sig.subscribe() only fires on future changes. Used directly, from(greenHouseViewModel.data$) would stay undefined until the next change. useSignal closes that gap with observe().
SolidStart Considerations
SolidStart is Solid's meta-framework for SSR and full-stack apps. ViewModel HTTP calls should be client-only — wrap the subscription setup in onMount or inside a client-side createEffect guarded by isServer:
import { isServer } from 'solid-js/web';
if (!isServer) {
greenHouseViewModel.fetchCommand.execute();
}For server-loaded initial data, use SolidStart's createAsync / query patterns for the initial load and hand the result to the ViewModel's initial state.
Run the demo
pnpm demo:start --frontends=mvvm-solid
# http://localhost:5179 (API on :8000)Summary
- Reactive state — Solid
createSignal()for view-only state; loom signals for ViewModels - Bridge a ViewModel signal —
useSignal(vm.data$)—observe()then a Solid accessor - Cleanup on unmount —
onCleanup()inside the hook - Render list —
<For each={items()}> - Conditional render —
<Show when={condition}> - Commands —
vm.someCommand.execute(payload)