Web Loom logo
MVVM CoreMVVM in Lit

MVVM in Lit

How Lit web components connect to Web Loom ViewModels using connectedCallback/disconnectedCallback for subscription lifecycle, @state for reactive re-renders, and the same Command pattern as every other framework.

MVVM in Lit

Lit builds UI from web components — standard custom elements that the browser natively understands. Where React has a virtual DOM and Angular has zone-based change detection, Lit has LitElement: a thin base class that adds property change observation and efficient template re-rendering on top of the native Custom Elements API.

The MVVM bridge in Lit maps cleanly onto two standard lifecycle callbacks: connectedCallback for subscribing, and disconnectedCallback for cleanup.


How Lit's Rendering Works

A LitElement subclass defines its output via a render() method that returns an html tagged template literal. Lit renders the initial output when the element connects to the DOM. After that, it only re-renders when a reactive property changes.

Reactive properties are declared with @property() (public, attribute-reflected) or @state() (private, internal only). When either changes, Lit schedules an efficient re-render of just the affected parts of the template.

element connects to DOM
    ↓
Lit calls render() → produces a TemplateResult
    ↓
Lit patches only the changed parts of the DOM
    ↓
@state() or @property() value changes
    ↓
Lit schedules and runs a minimal re-render

@web-loom/signals-core signals are invisible to this system until you connect them to a @state() field — exactly as you would connect them to useState in React or ref() in Vue.


The Subscription Bridge

The pattern in every Lit component in the Web Loom app is:

  1. Declare @state() private fields for every value rendered from the ViewModel
  2. Subscribe to ViewModel signals in connectedCallback with observe() (from @web-loom/signals-core), storing the returned unsubscribe functions
  3. In each observe() callback, assign the value to the corresponding @state() field
  4. Call all stored unsubscribe functions in disconnectedCallback
import { LitElement, html } from 'lit';
import { customElement, state } from 'lit/decorators.js';
import { observe } from '@web-loom/signals-core';
import { myViewModel } from './MyViewModel';
 
@customElement('my-component')
export class MyComponent extends LitElement {
  @state() private items: Item[] = [];
  @state() private isLoading = false;
 
  private teardowns: Array<() => void> = [];
 
  connectedCallback() {
    super.connectedCallback();
 
    this.teardowns.push(
      observe(myViewModel.data$, data => { this.items = data ?? []; }),
      observe(myViewModel.isLoading$, v  => { this.isLoading = v; }),
    );
 
    myViewModel.fetchCommand.execute();
  }
 
  disconnectedCallback() {
    super.disconnectedCallback();
    this.teardowns.forEach(teardown => teardown());
  }
 
  render() {
    return html`
      ${this.isLoading ? html`<p>Loading…</p>` : ''}
      <ul>
        ${this.items.map(item => html`<li>${item.name}</li>`)}
      </ul>
    `;
  }
}

observe(sig, fn) calls fn immediately with the signal's current value, then again on every future change — that's what makes this.items/this.isLoading correct from the very first render, not just after the first update. When this.items is assigned inside the callback, Lit detects the @state() change and schedules a re-render. The ViewModel never knows it's driving a web component.


Greenhouse List — Real Example

This is the actual greenhouse-list component from the Web Loom Lit app:

// apps/mvvm-lit/src/components/greenhouse-list.ts
import { LitElement, html } from 'lit';
import { customElement, state } from 'lit/decorators.js';
import { greenHouseViewModel, type GreenhouseData } from '@repo/view-models/GreenHouseViewModel';
import { observe } from '@web-loom/signals-core';
 
@customElement('greenhouse-list')
export class GreenhouseList extends LitElement {
  createRenderRoot() { return this; }  // use light DOM (no shadow root)
 
  @state() private greenhouses: GreenhouseData[] = [];
  private unsubscribeData: (() => void) | null = null;
 
  connectedCallback() {
    super.connectedCallback();
    this.unsubscribeData = observe(greenHouseViewModel.data$, (data: any) => {
      this.greenhouses = data;
    });
    greenHouseViewModel.fetchCommand.execute();
  }
 
  disconnectedCallback() {
    super.disconnectedCallback();
    this.unsubscribeData?.();
  }
 
  private handleSubmit(event: SubmitEvent) {
    event.preventDefault();
    const form = event.target as HTMLFormElement;
    const formData = new FormData(form);
    const name = formData.get('name') as string;
    const existing = this.greenhouses.find(gh => gh.name === name);
 
    if (existing) {
      greenHouseViewModel.updateCommand.execute({
        id: existing.id ?? '',
        payload: { ...existing, name, location: formData.get('location') as string },
      });
    } else {
      greenHouseViewModel.createCommand.execute({
        name,
        location: formData.get('location') as string,
        size: formData.get('size') as string,
        cropType: formData.get('cropType') as string,
      });
    }
    form.reset();
  }
 
  private handleDelete(id?: string) {
    if (id) greenHouseViewModel.deleteCommand.execute(id);
  }
 
  render() {
    return html`
      <section class="flex-container flex-row">
        <form class="form-container" @submit=${this.handleSubmit}>
          <input type="text" name="name" required placeholder="Greenhouse name" />
          <input type="text" name="location" required placeholder="Location" />
          <select name="size" required>
            <option value="25sqm">25sqm / Small</option>
            <option value="50sqm">50sqm / Medium</option>
            <option value="100sqm">100sqm / Large</option>
          </select>
          <input type="text" name="cropType" placeholder="Crop type" />
          <button type="submit">Submit</button>
        </form>
 
        <div class="card">
          <h1>Greenhouses</h1>
          ${this.greenhouses.length > 0
            ? html`<ul class="list">
                ${this.greenhouses.map(gh => html`
                  <li class="list-item">
                    <span>${gh.name}</span>
                    <button @click=${() => this.handleDelete(gh.id)}>Delete</button>
                  </li>
                `)}
              </ul>`
            : html`<p>No greenhouses found.</p>`}
        </div>
      </section>
    `;
  }
}

Several things to note:

createRenderRoot() returns this instead of a shadow root. This opts out of shadow DOM so the component inherits the app's global CSS. Use shadow DOM (the default) if you want full style encapsulation.

@submit=${this.handleSubmit} is Lit's event binding syntax — equivalent to React's onClick={handler} or Vue's @click="handler". Event listeners are scoped to the element and removed automatically when Lit updates the template.

The ViewModel interactiongreenHouseViewModel.createCommand.execute(...), greenHouseViewModel.deleteCommand.execute(...) — is unchanged from the React or Vue versions.


Dashboard: Combining Multiple Signals

When a component needs to coordinate loading state across multiple ViewModels, compose a computed() over each isLoading$ signal, then observe() the result into a single @state() field. From apps/mvvm-lit/src/components/dashboard-view.ts:

import { computed, observe } from '@web-loom/signals-core';
import { greenHouseViewModel } from '@repo/view-models/GreenHouseViewModel';
import { sensorViewModel } from '@repo/view-models/SensorViewModel';
import { sensorReadingViewModel } from '@repo/view-models/SensorReadingViewModel';
import { thresholdAlertViewModel } from '@repo/view-models/ThresholdAlertViewModel';
 
@customElement('dashboard-view')
export class DashboardView extends LitElement {
  createRenderRoot() { return this; }
 
  @state() private isLoading = true;
  private teardowns: Array<() => void> = [];
 
  connectedCallback() {
    super.connectedCallback();
 
    const anyLoading$ = computed(
      () =>
        greenHouseViewModel.isLoading$.get() ||
        sensorViewModel.isLoading$.get() ||
        sensorReadingViewModel.isLoading$.get() ||
        thresholdAlertViewModel.isLoading$.get(),
    );
    this.teardowns.push(
      observe(anyLoading$, (loading) => {
        this.isLoading = loading;
      }),
    );
 
    Promise.all([
      greenHouseViewModel.fetchCommand.execute(),
      sensorViewModel.fetchCommand.execute(),
      sensorReadingViewModel.fetchCommand.execute(),
      thresholdAlertViewModel.fetchCommand.execute(),
    ]);
  }
 
  disconnectedCallback() {
    super.disconnectedCallback();
    this.teardowns.forEach((teardown) => teardown());
  }
 
  render() {
    return html`
      ${this.isLoading
        ? html`<p>Loading dashboard data…</p>`
        : html`<dashboard-content></dashboard-content>`}
    `;
  }
}

computed() auto-tracks every signal read inside it (all four isLoading$.get() calls here), so anyLoading$ recomputes whenever any one of them changes — no manual coordination logic needed. This composition works identically in React, Vue, Angular, and Lit; only the assignment target (this.isLoading vs setValue vs loading.value) differs.


Shadow DOM vs Light DOM

By default, LitElement.createRenderRoot() creates a shadow root, giving the component full CSS isolation. The Web Loom app overrides this to use light DOM:

createRenderRoot() {
  return this;  // light DOM — inherits global styles
}

Use the default (shadow DOM) when the component needs to be self-contained with its own scoped styles. Use light DOM when the component should inherit the application's design system.

For connecting to @web-loom/design-core CSS variables, light DOM is simpler — custom properties do pierce shadow DOM boundaries, but other selectors do not.


Commands in Event Handlers

Lit's event binding syntax delegates to the class method:

@customElement('delete-button')
export class DeleteButton extends LitElement {
  private handleClick() {
    greenHouseViewModel.deleteCommand.execute(this.itemId);
  }
 
  render() {
    return html`
      <button @click=${this.handleClick}>Delete</button>
    `;
  }
}

For inline handlers in lists, use an arrow function to close over the item:

${this.items.map(item => html`
  <li>
    ${item.name}
    <button @click=${() => vm.deleteCommand.execute(item.id)}>Delete</button>
  </li>
`)}

Testing

LitElement components can be tested with @web/test-runner or @open-wc/testing. But because the ViewModel has no Lit imports, ViewModel logic is tested independently with plain Vitest — no element registration, no DOM:

it('filters greenhouses correctly', () => {
  const vm = new GreenHouseViewModel(mockModel);
  (mockModel.data$ as WritableSignal<any>).set([
    { id: '1', name: 'Ventura North', size: '25sqm' },
    { id: '2', name: 'Eastfield', size: '100sqm' },
  ]);
  const data = vm.data$.peek();
  expect(data).toHaveLength(2);
  vm.dispose();
});

Summary

The Lit integration maps directly onto the web component lifecycle:

  • Subscribe to signalsobserve() in connectedCallback
  • Trigger re-render on data change — assign to @state() field
  • Unsubscribe and clean up — call the stored teardown functions in disconnectedCallback
  • Call ViewModel Commands@event=${this.handler} or inline arrow function
  • Render templatehtml\`` tagged template literal in render()`

The ViewModel is unchanged. Lit components are thin, reusable, and composable as standard custom elements — they work in any HTML page, regardless of what other framework is on the page.

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