Web Loom logo
MVVM CoreMVVM in Marko

MVVM in Marko

How Marko combines server-streaming HTML with client-side reactivity, and how Web Loom ViewModels integrate via reactive let variables and a lightweight subscribeToObservable utility built on @web-loom/signals-core.

MVVM in Marko

Marko is a server-first, streaming UI framework developed at eBay. It compiles components to highly optimised streaming HTML for the initial render, then hydrates just the interactive parts on the client. It is one of the few frameworks designed from the ground up for partial hydration and fine-grained reactivity without a virtual DOM.

The Web Loom Marko app uses the same ViewModels as every other framework demo — the integration requires only a thin utility to bridge @web-loom/signals-core signals to Marko's reactive state variables.


How Marko's Reactivity Works

Marko uses a file-based component model. A .marko file contains TypeScript logic and HTML markup in one file, similar to Vue Single-File Components. State is declared with <let/> tags — when a let variable is reassigned, Marko surgically updates the affected parts of the DOM without re-running the whole component.

<!-- Declare reactive state -->
<let/count=0>
<let/items=[] as Item[]>
 
<!-- Bind to state in the template -->
<p>${count}</p>
<for|item| of=items>
  <li>${item.name}</li>
</for>
 
<!-- Inline event handlers -->
<button onClick=() => { count++; }>Increment</button>

Marko's <script> block runs on mount (after the component connects to the client). Returning a function from <script> registers a cleanup callback — the equivalent of React's useEffect cleanup or Vue's onUnmounted.


The Observable Bridge Utility

The Marko app includes a small utility that translates @web-loom/signals-core signals into Marko-compatible state updates. It kept its original RxJS-era name (subscribeToObservable) since Marko templates already reference it, but it's signal-based internally:

// apps/mvvm-marko/src/utils/marko-observable.ts
import { observe, type ReadonlySignal } from '@web-loom/signals-core';
 
/**
 * Subscribe to a signal and update a Marko state variable.
 * Returns cleanup function for effect.
 *
 * With skipFirst = false (default) the current value is delivered
 * immediately, mirroring the previous BehaviorSubject behavior.
 */
export function subscribeToObservable<T>(sig: ReadonlySignal<T>, updateFn: (value: T) => void, skipFirst = false) {
  if (skipFirst) {
    return sig.subscribe(updateFn);
  }
  return observe(sig, updateFn);
}

subscribeToObservable returns an unsubscribe function, which integrates naturally with the <script> cleanup pattern.

The skipFirst option skips delivering the signal's current value immediately, using plain sig.subscribe() (future changes only) instead of observe(). This is useful when the server has already rendered the initial state from a promise-based fetch and you only want to react to subsequent client-side changes.


Greenhouse Page — Real Example

This is the actual greenhouse page from the Marko app:

<!-- apps/mvvm-marko/src/routes/greenhouses/+page.marko -->
import { greenHouseViewModel, type GreenhouseListData }
  from '@repo/view-models/GreenHouseViewModel';
import { subscribeToObservable } from '../../utils/marko-observable';
 
<!-- Reactive state variables -->
<let/greenhouses=[] as GreenhouseListData>
<let/formData={ name: '', location: '', size: '', cropType: '' }>
<let/editingId=null as string | null>
<let/statusMessage=''>
<let/isLoading=true>
 
<!-- Event handlers -->
<const/handleSubmit=async (event: Event) => {
  event.preventDefault();
  const payload = {
    name: formData.name.trim(),
    location: formData.location.trim(),
    size: formData.size.trim(),
    cropType: formData.cropType.trim(),
  };
  if (editingId) {
    await greenHouseViewModel.updateCommand.execute({ id: editingId, payload });
    editingId = null;
    statusMessage = 'Greenhouse updated.';
  } else {
    await greenHouseViewModel.createCommand.execute(payload);
    statusMessage = 'Greenhouse created.';
  }
  formData = { name: '', location: '', size: '', cropType: '' };
}>
 
<const/handleDeleteClick=async (event: Event) => {
  const id = (event.currentTarget as HTMLButtonElement).dataset.id;
  if (id) {
    await greenHouseViewModel.deleteCommand.execute(id);
    statusMessage = 'Greenhouse deleted.';
  }
}>
 
<!-- Mount: subscribe to signals, return cleanup -->
<script>
  greenHouseViewModel.fetchCommand.execute();
 
  const unsubData = subscribeToObservable(
    greenHouseViewModel.data$,
    (value: GreenhouseListData | null) => { greenhouses = value ?? []; },
  );
  const unsubLoading = subscribeToObservable(
    greenHouseViewModel.isLoading$,
    (value: boolean) => { isLoading = value; },
  );
 
  return () => {
    unsubData();
    unsubLoading();
  };
</script>
 
<!-- Template -->
<section>
  <form onSubmit=handleSubmit>
    <input name="name" value=formData.name onInput=(e) => { formData = { ...formData, name: e.target.value }; } required>
    <textarea name="location" onInput=(e) => { formData = { ...formData, location: e.target.value }; } required></textarea>
    <select name="size" value=formData.size onInput=(e) => { formData = { ...formData, size: e.target.value }; } required>
      <option value="25sqm">25sqm / Small</option>
      <option value="50sqm">50sqm / Medium</option>
      <option value="100sqm">100sqm / Large</option>
    </select>
    <button type="submit">
      ${editingId ? 'Update greenhouse' : 'Save greenhouse'}
    </button>
  </form>
 
  <if=isLoading>
    <p>Loading greenhouse data…</p>
  </if>
  <else>
    <ul>
      <for|gh| of=greenhouses>
        <li>
          <strong>${gh.name}</strong>
          <span>${gh.location}</span>
          <button data-id=gh.id onClick=handleDeleteClick>Delete</button>
        </li>
      </for>
    </ul>
  </else>
 
  <if=statusMessage>
    <p>${statusMessage}</p>
  </if>
</section>

The ViewModel calls are identical to every other framework: fetchCommand.execute(), updateCommand.execute(...), deleteCommand.execute(...). The only Marko-specific parts are <let/>, <const/>, <script>, <if>, <for>.


The Script Block Lifecycle

Marko's <script> block is the mount hook. Code runs after the component connects to the client DOM. Return a function to run cleanup on unmount:

<script>
  // runs on mount
  const unsub = subscribeToObservable(vm.data$, value => { items = value; });
 
  // returned function runs on unmount
  return () => unsub();
</script>

This is conceptually identical to React's:

useEffect(() => {
  return vm.data$.subscribe(setValue);
}, []);

Or Vue's:

const unsubscribe = observe(vm.data$, v => (ref.value = v));
onUnmounted(unsubscribe);

The pattern is universal — subscribe (or observe) on mount, call the returned unsubscribe function on unmount. Only the syntax differs.


Marko Tags Reference

  • <let/x=value> — reactive state variable (equivalent to useState / ref())
  • <const/fn=...> — stable event handler (equivalent to useCallback / const fn =)
  • <script> — mount lifecycle hook (equivalent to useEffect / onMounted)
  • <if=condition> — conditional render (equivalent to {condition && ...} / v-if)
  • <else> — else branch (equivalent to v-else)
  • <for|item| of=list> — list render (equivalent to .map() / v-for)
  • <await=promise> — progressive / streaming async render (equivalent to Suspense / <Await>)

SSR and Progressive Rendering

Marko's primary advantage over purely client-side frameworks is streaming HTML to the browser before JavaScript loads. You can use <await> to progressively render async data:

<!-- Convert the current signal value to a Promise for SSR -->
import { observableToPromise } from '../../utils/marko-observable';
import { greenHouseViewModel } from '@repo/view-models/GreenHouseViewModel';
 
<await=observableToPromise(greenHouseViewModel.data$)>
  <@then|greenhouses|>
    <ul>
      <for|gh| of=greenhouses>
        <li>${gh.name}</li>
      </for>
    </ul>
  </@then>
  <@catch|err|>
    <p>Failed to load: ${err.message}</p>
  </@catch>
</await>

The observableToPromise utility resolves the signal's current value (.peek()) as a Promise, which Marko's <await> tag can stream to the client progressively.

// apps/mvvm-marko/src/utils/marko-observable.ts
import { type ReadonlySignal } from '@web-loom/signals-core';
 
export function observableToPromise<T>(sig: ReadonlySignal<T>): Promise<T> {
  return Promise.resolve(sig.peek());
}

This is one of the places where Marko's architecture is genuinely different from the SPA frameworks — the server renders real HTML that the browser can paint before any JavaScript executes. Every Web Loom signal always holds a current value synchronously (readable via .peek()), the same way a BehaviorSubject did before the migration, making it compatible with this pattern.


Testing

ViewModels have no Marko imports and test identically to every other framework integration:

import { describe, it, expect, vi } from 'vitest';
import type { WritableSignal } from '@web-loom/signals-core';
import { GreenHouseViewModel } from '@repo/view-models/GreenHouseViewModel';
 
it('data$ reflects the fetched list', () => {
  const vm = new GreenHouseViewModel(mockModel);
  (mockModel.data$ as WritableSignal<any>).set([
    { id: '1', name: 'Ventura North', location: 'CA', size: '25sqm', cropType: 'Lettuce' },
  ]);
  const data = vm.data$.peek();
  expect(data).toHaveLength(1);
  vm.dispose();
});

Summary

  • Declare reactive state<let/name=initialValue>
  • Subscribe to signals<script> block (mount hook)
  • Update state from subscription — reassign the <let> variable inside the callback
  • Unsubscribe — return a cleanup function from <script>
  • Render template — Marko HTML with <if>, <for>, ${expr}
  • Call ViewModel Commandsvm.someCommand.execute(payload) in event handlers
  • SSR initial dataobservableToPromise + <await> tag

The ViewModel is shared with every other platform. The Marko-specific surface area is limited to state declaration syntax (<let/>) and the mount hook (<script>) — the rest is plain TypeScript and HTML.

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