Web Loom logo
MVVM CoreMVVM in Solid

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-solid is the Solid twin of apps/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)
Was this helpful?
Web Loom logo
Copyright © Web Loom. All rights reserved.