Web Loom logo
Published PackagesGetting Started

Getting Started

What Web Loom is, why framework-agnostic architecture matters, and how to install the packages and build your first ViewModel in minutes.

Getting Started

Web Loom is built on a single conviction: business logic should survive framework changes. When you migrate from React to Vue, or add a React Native mobile app alongside your web app, only the View layer — roughly 20% of your codebase — should need rewriting. The remaining 80% (Models, ViewModels, services, and behaviors) lives in framework-agnostic packages and travels unchanged.


MVVM Has Been Around Forever — For Good Reason

MVVM is not a new idea. John Gossman invented it at Microsoft in 2005, specifically for WPF (Windows Presentation Foundation). The insight was simple and profound: the part of your UI logic that formats data, tracks loading state, and responds to user actions has nothing inherently to do with the rendering layer. Separate them, and you get code that is testable, portable, and long-lived.

The pattern spread immediately. Xamarin built entire mobile app architectures on it. Silverlight standardized around it. When Knockout.js launched in 2010 — years before React existed — it brought MVVM directly to the browser. Developers building data-heavy web apps in that era had clean, well-structured codebases with observable ViewModels and declarative bindings. It worked.

Today, MVVM remains the dominant architecture in every major non-web UI platform:

  • Android — Jetpack's ViewModel + LiveData / StateFlow is MVVM. Google made it the official architecture guide.
  • WPF / .NET MAUI — MVVM is still the standard. The community toolkit ships ObservableObject, RelayCommand, and INotifyPropertyChanged as first-class abstractions.
  • SwiftUI — Apple's ObservableObject + @Published is MVVM with Swift syntax.
  • Avalonia UI — the cross-platform .NET UI framework documents MVVM as the recommended pattern.
  • Flutter — Provider, Riverpod, and the BLoC pattern are all MVVM by another name.

These platforms chose MVVM because it solves a real, hard problem: separating what your app knows from how it looks. The solution is good enough that it has survived multiple technology generations without needing replacement.

Web Development's Pattern Amnesia

Then there is the web.

React arrived in 2013 and the frontend community largely abandoned everything that came before it. What followed was over a decade of constant pattern reinvention. Flux was created to manage state in React apps. Then Redux. Then MobX. Then the Context API, useReducer, Zustand, Jotai, Recoil, Valtio, XState, TanStack Query, SWR, Nanostores — the list is effectively endless, and it keeps growing.

Most of these libraries solve the same underlying problems MVVM solved in 2005: where does async state live, how do components know when data changes, how do you separate side effects from rendering? They are not new ideas. They are variations on the same theme, wearing different hats.

The result for the average web codebase is genuinely messy. Business logic is scattered across components, custom hooks, global stores, server functions, and context providers. A developer joining a project has to understand not just the product, but the specific combination of patterns the team invented or assembled over the years. When the next framework wave arrives — and it always does — there is nothing portable to carry forward.

The reason web developers keep reinventing these patterns is not that the old patterns are bad. It is that they never stopped to use a good one.

Why This Matters More in the Age of LLMs

AI-assisted development has made it faster than ever to write code. With LLMs and agentic code generation, a developer can produce a working feature in a fraction of the time it used to take. This is genuinely useful.

But it compounds the architecture problem. An LLM will generate code that fits the context you give it. If your codebase has no clear structure — if business logic leaks into components, if state management is ad hoc, if there is no consistent pattern for async operations — the generated code will follow that same lack of structure. It accelerates the mess.

A well-defined architecture is the opposite. When your codebase has clear layers with strict responsibilities, AI-generated code slots into the right place naturally. The ViewModel is where async operations and derived state go. The Model is where API calls and data ownership go. The View is thin and renders what the ViewModel exposes. An LLM generating code for a well-structured MVVM project produces additions that are coherent with everything that already exists.

Good patterns do not slow you down. They are what allow you to move fast sustainably — whether you are writing the code or an AI is generating it.

Web Loom is a bet that the frontend community can borrow the forty years of desktop and mobile architecture wisdom that other platforms have been quietly using all along, apply it to the browser, and stop reinventing the wheel with every new framework cycle.


The MVVM Pattern

Web Loom follows the Model–View–ViewModel (MVVM) pattern, a separation of concerns that decouples data, presentation logic, and UI rendering.

Model          →   owns data, talks to APIs, holds reactive state
  ↓
ViewModel      →   derives presentation state, exposes commands
  ↓
View           →   framework-specific rendering and subscriptions

Each layer has a strict responsibility:

  • Model — fetches, persists, and owns raw data. Exposes it through reactive signals. Never knows the UI exists.
  • ViewModel — computes what the View needs (formatted values, loading flags, derived lists) and exposes commands for actions. Orchestrates one or more Models. No framework imports.
  • View — reads ViewModel signals, renders them, and calls ViewModel commands on user interaction. The only framework-specific code.

The Layered Architecture

┌─────────────────────────────────┐
│  View (framework-specific)      │  React / Vue / Angular / Lit / Vanilla
│  reads signals via a bridge     │
├─────────────────────────────────┤
│  ViewModel (framework-agnostic) │  Commands, derived state, orchestration
│  uses Models, exposes state     │
├─────────────────────────────────┤
│  Model (framework-agnostic)     │  Data, API calls, reactive signals
│  calls infrastructure           │
├─────────────────────────────────┤
│  Infrastructure                 │  HTTP, Storage, i18n, Router, EventBus
└─────────────────────────────────┘

Cross-cutting concerns used across all layers:

  • Event Bus — typed pub/sub for cross-feature communication without coupling
  • Store — UI-only ephemeral state (sidebar open, active tab, theme)
  • Signals — the reactive primitives (from @web-loom/signals-core) that propagate changes through the graph; RxJS is available as an optional interop, not a core dependency
  • Notifications — user-facing feedback (toasts, alerts)

Why Framework-Agnostic

Frameworks have lifecycles. React 16 → 18, Angular upgrades, Vue 2 → 3 — each migration forces rewrites. When business logic is entangled with framework code, every migration touches the whole codebase.

Web Loom separates concerns at a package boundary:

  • @web-loom/mvvm-core, @web-loom/signals-core, @web-loom/query-core — plain TypeScript, zero framework imports
  • @web-loom/ui-core, @web-loom/forms-core — headless behaviors, framework-agnostic
  • Framework adapters (@web-loom/forms-react, @web-loom/media-vue, …) — thin wrappers, typically under 200 lines

When a new framework emerges, you write a new adapter. The Models and ViewModels underneath stay untouched.


Installation

Install the packages you need. At minimum, mvvm-core (which pulls in @web-loom/signals-core as a dependency) and zod:

npm install @web-loom/mvvm-core zod

For data fetching, UI state, and headless behaviors, add the rest:

npm install @web-loom/query-core @web-loom/store-core @web-loom/ui-core @web-loom/event-bus-core zod

RxJS is not required — reach for it only if you're integrating with an existing RxJS-based codebase (see Reactive State below).


Your First ViewModel

This example wires up a complete Model → ViewModel → View flow using @web-loom/mvvm-core.

1. Define the Model

The Model owns data and API calls. It exposes reactive signals but never imports anything from a UI framework.

import { BaseModel } from '@web-loom/mvvm-core';
import { z } from 'zod';
 
const TaskSchema = z.object({ id: z.string(), title: z.string(), done: z.boolean() });
const TaskListSchema = z.array(TaskSchema);
type Task = z.infer<typeof TaskSchema>;
 
export class TaskModel extends BaseModel<Task[], typeof TaskListSchema> {
  constructor() {
    super({ initialData: [], schema: TaskListSchema });
  }
 
  async fetchAll() {
    this.setLoading(true);
    this.clearError();
    try {
      const res  = await fetch('/api/tasks');
      const data = await res.json();
      this.setData(data);
    } catch (err) {
      this.setError(err as Error);
    } finally {
      this.setLoading(false);
    }
  }
 
  async create(title: string) {
    const res  = await fetch('/api/tasks', { method: 'POST', body: JSON.stringify({ title }) });
    const task = await res.json();
    this.setData([...(this.getCurrentData() ?? []), task]);
  }
}

data$, isLoading$, and error$ — inherited from BaseModel — are ReadonlySignal<T> from @web-loom/signals-core.

2. Create the ViewModel

The ViewModel derives what the View needs from the Model and exposes Commands for user actions. No framework imports.

import { BaseViewModel, Command } from '@web-loom/mvvm-core';
import { computed } from '@web-loom/signals-core';
import { TaskModel } from './TaskModel';
 
export class TaskListViewModel extends BaseViewModel<TaskModel> {
  // Derived state — count of incomplete tasks
  readonly pendingCount$ = computed(() => (this.data$.get() ?? []).filter((t) => !t.done).length);
 
  // Commands — wrap async operations, expose isExecuting$ and canExecute$
  readonly fetchCommand = this.registerCommand(
    new Command(async () => { await this.model.fetchAll(); }),
  );
 
  readonly addCommand = this.registerCommand(
    new Command(async (title: string) => { await this.model.create(title); }),
  );
 
  constructor() {
    super(new TaskModel());
  }
}
 
export const taskListViewModel = new TaskListViewModel();

data$/isLoading$/error$ don't need to be redeclared — BaseViewModel already re-exposes the Model's signals directly.

3. Connect to a View

The View reads ViewModel signals (through a small per-framework bridge, since each framework has its own way to trigger a re-render) and calls Commands. This is the only framework-specific code.

React

import { useSyncExternalStore, useEffect, useMemo } from 'react';
import type { ReadonlySignal } from '@web-loom/signals-core';
import { taskListViewModel } from './TaskListViewModel';
 
function useSignal<T>(sig: ReadonlySignal<T>): T {
  return useSyncExternalStore(sig.subscribe, sig.get, sig.get);
}
 
export function TaskList() {
  const vm       = taskListViewModel;
  const tasks    = useSignal(vm.data$);
  const loading  = useSignal(vm.isLoading$);
  const pending  = useSignal(vm.pendingCount$);
 
  useEffect(() => {
    vm.fetchCommand.execute();
    return () => vm.dispose();
  }, []);
 
  return (
    <div>
      <h1>Tasks ({pending} pending)</h1>
      {loading && <p>Loading…</p>}
      <ul>
        {tasks?.map((t) => <li key={t.id}>{t.title}</li>)}
      </ul>
      <button onClick={() => vm.addCommand.execute('New task')}>Add</button>
    </div>
  );
}

Vue 3

<script setup lang="ts">
import { onMounted, onUnmounted, shallowRef, type ShallowRef } from 'vue';
import { observe, type ReadonlySignal } from '@web-loom/signals-core';
import { taskListViewModel } from './TaskListViewModel';
 
function useSignal<T>(sig: ReadonlySignal<T>): ShallowRef<T> {
  const value = shallowRef<T>(sig.peek());
  const unsubscribe = observe(sig, (next) => { value.value = next; });
  onUnmounted(unsubscribe);
  return value;
}
 
const vm = taskListViewModel;
const tasks   = useSignal(vm.data$);
const loading = useSignal(vm.isLoading$);
const pending = useSignal(vm.pendingCount$);
 
onMounted(() => vm.fetchCommand.execute());
onUnmounted(() => vm.dispose());
</script>
 
<template>
  <div>
    <h1>Tasks ({{ pending }} pending)</h1>
    <p v-if="loading">Loading…</p>
    <ul>
      <li v-for="t in tasks" :key="t.id">{{ t.title }}</li>
    </ul>
    <button @click="vm.addCommand.execute('New task')">Add</button>
  </div>
</template>

Angular

import { Component, OnInit, OnDestroy, InjectionToken, Inject } from '@angular/core';
import { CommonModule } from '@angular/common';
import { fromLoomSignal } from './utils/loom-signals';
import { taskListViewModel } from './TaskListViewModel';
 
export const TASK_VM = new InjectionToken('TASK_VM');
 
@Component({
  standalone: true,
  imports: [CommonModule],
  providers: [{ provide: TASK_VM, useValue: taskListViewModel }],
  template: `
    <div *ngIf="loading()">Loading…</div>
    <ul>
      <li *ngFor="let t of tasks()">{{ t.title }}</li>
    </ul>
    <button (click)="vm.addCommand.execute('New task')">Add</button>
  `,
})
export class TaskListComponent implements OnInit, OnDestroy {
  constructor(@Inject(TASK_VM) public vm: typeof taskListViewModel) {}
 
  tasks   = fromLoomSignal(this.vm.data$);
  loading = fromLoomSignal(this.vm.isLoading$);
 
  ngOnInit()    { this.vm.fetchCommand.execute(); }
  ngOnDestroy() { this.vm.dispose(); }
}

fromLoomSignal bridges a Web Loom signal into a native Angular signal, torn down via DestroyRef — see MVVM in Angular for the full implementation.

The ViewModel class is the same in all three cases. Only the bridge syntax differs.


Reactive State

Signals

@web-loom/mvvm-core is built on @web-loom/signals-core — every data$/isLoading$/error$/canExecute$/isExecuting$ you'll see throughout these docs is a ReadonlySignal<T>: synchronous, pull-based, with automatic dependency tracking. Zero dependencies, no scheduler.

import { signal, computed } from '@web-loom/signals-core';
 
class CounterViewModel {
  private _count = signal(0);
  readonly count   = this._count.asReadonly();
  readonly doubled = computed(() => this._count.get() * 2);
 
  increment() { this._count.update((v) => v + 1); }
}

RxJS (optional interop)

RxJS is not a dependency of mvvm-core — the package doesn't use it internally. If you're integrating with an existing RxJS-based codebase, or want RxJS operators (debounce pipelines, retry/backoff, websocket streams) at the edge of a Model, bridge via the optional @web-loom/signals-core/rxjs subpath (requires rxjs as a peer dependency):

import { toObservable, fromObservable } from '@web-loom/signals-core/rxjs';
 
const count$ = toObservable(counterViewModel.count); // Signal -> Observable (BehaviorSubject semantics)

See Signals Core: RxJS Interop for the full API.


The Command Pattern

Commands are the primary mechanism for user actions. A Command wraps an async operation and exposes:

  • execute() — trigger the action
  • isExecuting$ / isExecuting — loading state for spinner binding
  • canExecute$ / canRun — guards (e.g. disable submit while loading)
  • error$ / error — last error, if any
// In a ViewModel
readonly saveCommand = new Command(async () => {
  await this.model.save(this.form.values);
});
 
// In the View (React)
<button
  onClick={() => vm.saveCommand.execute()}
  disabled={!vm.saveCommand.canExecute}
>
  {vm.saveCommand.isExecuting ? 'Saving…' : 'Save'}
</button>

Commands keep the View dumb — it never contains try/catch, loading flags, or validation logic.


Key Principles

Unidirectional data flow — state flows down (Model → ViewModel → View), actions flow up (View calls ViewModel commands). No two-way data binding at the architecture level.

Always dispose — ViewModels register Commands and manual signal subscriptions. Call vm.dispose() in the component teardown hook to prevent memory leaks.

// React
useEffect(() => {
  vm.fetchCommand.execute();
  return () => vm.dispose();
}, []);

Business data in Models, UI state in Store — a filter value that affects API results belongs in the Model. Whether a drawer is open belongs in the Store.

Test ViewModels independently — because ViewModels have no framework imports, they can be unit tested with plain Vitest, no DOM or component setup required.

it('counts pending tasks correctly', () => {
  const vm = new TaskListViewModel();
  vm['model'].setData([
    { id: '1', title: 'Buy milk', done: false },
    { id: '2', title: 'Write tests', done: true },
  ]);
  expect(vm.pendingCount$.peek()).toBe(1);
  vm.dispose();
});

Package Ecosystem

Web Loom ships packages across several categories:

Core architecture

Data & communication

UI behaviors

  • @web-loom/ui-core — Headless dialog, list, form, roving-focus behaviors
  • @web-loom/ui-patterns — Wizard, MasterDetail, CommandPalette shells
  • @web-loom/forms-core — Framework-agnostic form logic and validation

Design & theming

Infrastructure

  • @web-loom/router-core — Routing state abstraction
  • @web-loom/storage-core — localStorage / sessionStorage abstraction
  • @web-loom/i18n-core — Internationalization
  • @web-loom/plugin-core — Plugin architecture for extensible applications

Where to Go Next

  • Core Concepts — deeper dive into architecture patterns and how packages relate
  • Models — the Model layer in detail: RestfulApiModel, schemas, reactive streams
  • ViewModels — Commands, RestfulApiViewModel, FormViewModel, lifecycle
  • Signals Core — the signal/computed/effect primitives underlying mvvm-core
  • MVVM in React — React-specific integration patterns
  • MVVM in Vue — Vue 3 composable-based integration
  • MVVM in Angular — Angular DI and signal bridging integration
  • MVVM in Vanilla TS — framework-free integration with manual DOM rendering
Was this helpful?
Web Loom logo
Copyright © Web Loom. All rights reserved.