Web Loom logo
MVVM CoreMVVM in Solid

MVVM in Solid

How Web Loom ViewModels connect to SolidJS using createSignal and onCleanup to bridge @web-loom/signals-core signals, matching the pattern used in the create-web-loom Solid template.

MVVM in Solid

Status: No demo app exists yet. This page documents the integration pattern for teams adopting Solid.

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. This makes Solid exceptionally fast.

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. The bridge is explicit and lightweight — it's the same useSignalValue hook used in the create-web-loom Solid template.


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 useSignalValue Bridge

The create-web-loom Solid template ships a small hook that mirrors a Web Loom signal into a Solid signal, syncing on every change and cleaning up via onCleanup:

// src/hooks/useObservable.ts
import { createSignal, onCleanup } from 'solid-js';
 
type SignalLike<T> = {
  get: () => T;
  subscribe: (fn: () => void) => () => void;
};
 
export function useSignalValue<T>(signal: SignalLike<T>) {
  const [value, setValue] = createSignal(signal.get());
 
  const sync = () => {
    setValue(signal.get());
  };
 
  sync(); // seed with the current value immediately
  const unsubscribe = signal.subscribe(sync);
  onCleanup(unsubscribe);
 
  return value;
}

sync() is called once immediately (since sig.subscribe() only fires on future changes), then registered as the change handler. onCleanup — Solid's equivalent of React's useEffect cleanup or Vue's onUnmounted — unsubscribes when the owning component or effect is disposed.

// GreenhouseList.tsx
import { For, Show } from 'solid-js';
import { greenHouseViewModel, type GreenhouseData } from '@repo/view-models/GreenHouseViewModel';
import { useSignalValue } from '../hooks/useObservable';
 
export function GreenhouseList() {
  const greenhouses = useSignalValue(greenHouseViewModel.data$);
  const isLoading = useSignalValue(greenHouseViewModel.isLoading$);
 
  greenHouseViewModel.fetchCommand.execute(); // fires once when the component initializes
 
  return (
    <div>
      <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>
    </div>
  );
}

Commands

Commands are plain async calls. Bind isExecuting$ with the same useSignalValue hook:

import { useSignalValue } from '../hooks/useObservable';
 
export function RefreshButton() {
  const isExecuting = useSignalValue(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, though: from() seeds its Solid signal with undefined and waits for the first callback invocation to get a value, but sig.subscribe() only fires on future changes, not the current one. Used directly, from(greenHouseViewModel.data$) would stay undefined until the next change — useSignalValue above exists specifically to close that gap by calling sync() once up front.


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.


Summary

  • Reactive statecreateSignal()
  • Bridge a ViewModel signaluseSignalValue(vm.data$) — seeds from .get(), then .subscribe()
  • Cleanup on unmountonCleanup() inside the hook
  • Render list<For each={items()}>
  • Conditional render<Show when={condition}>
  • Commandsvm.someCommand.execute(payload)

useSignalValue is the one hook every component needs — it's small enough to keep inline and covers data$, isLoading$, error$, and Command signals identically.

Was this helpful?
Web Loom logo
Copyright © Web Loom. All rights reserved.