Web Loom logo
Package Deep Dives · Part 11 of 11

Putting It All Together — Building a Complete Feature With Web Loom

We've spent ten articles looking at individual packages. Each one solves a specific problem. Each one is useful on its own.

We've spent ten articles looking at individual packages. Each one solves a specific problem. Each one is useful on its own.

But the real question is whether the pieces compose. Can you build something real, something that a team would actually ship, by assembling these packages together? Or does the architecture fall apart when you try to use five packages at once?

This final article builds a complete feature from scratch: a Product Browser — a master-detail list of products, searchable, with a delete-with-confirmation flow, live event updates, cached data fetching, theme support, and a command palette for power users. It's a representative slice of a real application.

By the end, almost every package in the series will have appeared (the table at the end notes the one that doesn't).


What We're Building

The feature has these requirements:

  1. Load a list of products from an API, with caching
  2. Display them in a searchable, paginated master-detail layout
  3. Allow deleting a product with a confirmation dialog (no DOM coupling in the ViewModel)
  4. Notify other parts of the app when a product is deleted (cross-feature event)
  5. Persist the user's selected theme and sidebar preference
  6. Support a command palette for keyboard-driven power users
  7. Apply a design token system so the UI is consistently themeable
  8. Have ViewModels that pause gracefully when their tab is not active

The packages used:

  • @web-loom/mvvm-core — BaseModel, BaseViewModel, Command
  • @web-loom/query-core — caching layer
  • @web-loom/event-bus-core — cross-feature notifications
  • @web-loom/store-core — UI state (theme, sidebar)
  • @web-loom/ui-patterns — MasterDetail, CommandPalette
  • @web-loom/ui-core — headless form behaviour for the search field
  • @web-loom/mvvm-patterns — ConfirmationRequest, ActiveAwareViewModel
  • @web-loom/design-core — design tokens and CSS custom properties
  • @web-loom/signals-core — reactive computed values
  • @web-loom/event-emitter-core — typed events on the model

Step 1: The Design Foundation

Before any code, define the design tokens. These will flow through everything.

// src/design/tokens.ts
import type { ColorValue, SpacingValue, BorderRadiusValue, DesignTokenValue } from '@web-loom/design-core';
 
// A few lines of our own to turn a token record into CSS declarations
const toCss = (...groups: Record<string, DesignTokenValue>[]) =>
  groups.flatMap((g) => Object.entries(g).map(([name, t]) => `--${name}: ${t.value};`)).join(' ');
 
const colors: Record<string, ColorValue> = {
  'color-brand': { value: '#2563EB', type: 'color' },
  'color-surface': { value: '#FFFFFF', type: 'color' },
  'color-surface-subtle': { value: '#F8FAFC', type: 'color' },
  'color-surface-raised': { value: '#F1F5F9', type: 'color' },
  'color-text-primary': { value: '#0F172A', type: 'color' },
  'color-text-secondary': { value: '#64748B', type: 'color' },
  'color-text-inverse': { value: '#FFFFFF', type: 'color' },
  'color-border': { value: '#E2E8F0', type: 'color' },
  'color-danger': { value: '#DC2626', type: 'color' },
  'color-success': { value: '#16A34A', type: 'color' },
};
 
const spacing: Record<string, SpacingValue> = {
  'space-1': { value: '4px', type: 'spacing' },
  'space-2': { value: '8px', type: 'spacing' },
  'space-3': { value: '12px', type: 'spacing' },
  'space-4': { value: '16px', type: 'spacing' },
  'space-6': { value: '24px', type: 'spacing' },
  'space-8': { value: '32px', type: 'spacing' },
};
 
const radii: Record<string, BorderRadiusValue> = {
  'radius-sm': { value: '4px', type: 'borderRadius' },
  'radius-md': { value: '8px', type: 'borderRadius' },
  'radius-lg': { value: '12px', type: 'borderRadius' },
};
 
export const lightThemeCSS = toCss(colors, spacing, radii);
 
export const darkColors: Record<string, ColorValue> = {
  'color-brand': { value: '#3B82F6', type: 'color' },
  'color-surface': { value: '#0F172A', type: 'color' },
  'color-surface-subtle': { value: '#1E293B', type: 'color' },
  'color-surface-raised': { value: '#334155', type: 'color' },
  'color-text-primary': { value: '#F1F5F9', type: 'color' },
  'color-text-secondary': { value: '#94A3B8', type: 'color' },
  'color-text-inverse': { value: '#0F172A', type: 'color' },
  'color-border': { value: '#334155', type: 'color' },
  'color-danger': { value: '#F87171', type: 'color' },
  'color-success': { value: '#4ADE80', type: 'color' },
};
 
export const darkThemeCSS = toCss(darkColors, spacing, radii);

Inject both at app startup, swapping between them via a data-theme attribute or .dark class:

// src/main.ts
import { lightThemeCSS, darkThemeCSS } from './design/tokens';
 
const style = document.createElement('style');
style.textContent = `:root { ${lightThemeCSS} } .dark { ${darkThemeCSS} }`;
document.head.appendChild(style);

Every component now uses var(--color-surface), var(--space-4), var(--radius-md). Themes are a class toggle.


Step 2: The Event Bus

Set up the application event bus before any feature code. This is the central nervous system for cross-feature communication.

// src/infrastructure/bus.ts
import { createEventBus, type EventMap } from '@web-loom/event-bus-core';
 
// Define all app-level events in one place
interface AppEvents extends EventMap {
  'product:deleted': [{ productId: string; name: string }];
  'product:updated': [{ productId: string }];
  'cart:item-added': [{ productId: string; quantity: number }];
  'auth:logout': [];
}
 
export const appBus = createEventBus<AppEvents>();

This bus lives outside any feature. Both the product feature and the cart feature can communicate through it without importing each other.


Step 3: The UI Store

UI state lives in a dedicated store. No framework, no context, just state.

// src/stores/ui-store.ts
// Persistence lives in the /persist subpath (its createStore takes a third argument)
import { createStore, LocalStorageAdapter } from '@web-loom/store-core/persist';
 
interface UIState {
  theme: 'light' | 'dark' | 'system';
  sidebarOpen: boolean;
  commandPaletteOpen: boolean;
}
 
interface UIActions {
  setTheme: (theme: UIState['theme']) => void;
  toggleSidebar: () => void;
  openPalette: () => void;
  closePalette: () => void;
}
 
export const uiStore = createStore<UIState, UIActions>(
  {
    theme: 'system',
    sidebarOpen: true,
    commandPaletteOpen: false,
  },
  (set) => ({
    setTheme: (theme: UIState['theme']) => set((s) => ({ ...s, theme })),
    toggleSidebar: () => set((s) => ({ ...s, sidebarOpen: !s.sidebarOpen })),
    openPalette: () => set((s) => ({ ...s, commandPaletteOpen: true })),
    closePalette: () => set((s) => ({ ...s, commandPaletteOpen: false })),
  }),
  {
    key: 'app:ui',
    adapter: new LocalStorageAdapter(),
    merge: true,
  },
);
 
// Apply theme to DOM whenever it changes
uiStore.subscribe(({ theme }, prev) => {
  if (theme !== prev?.theme) {
    const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
    const isDark = theme === 'dark' || (theme === 'system' && prefersDark);
    document.documentElement.classList.toggle('dark', isDark);
  }
});

Step 4: The Product Model

The Model owns the data contract. It fetches, caches, and exposes typed events.

// src/features/products/ProductModel.ts
import { BaseModel } from '@web-loom/mvvm-core';
import { QueryCore } from '@web-loom/query-core';
import { EventEmitter } from '@web-loom/event-emitter-core';
import { appBus } from '../../infrastructure/bus';
 
export interface Product {
  id: string;
  name: string;
  category: string;
  price: number;
  stock: number;
  imageUrl: string;
}
 
interface ProductModelEvents {
  deleted: { productId: string; name: string };
}
 
// One QueryCore instance for the feature — could be app-level and shared
const queryCache = new QueryCore({ cacheProvider: 'localStorage', defaultRefetchAfter: 5 * 60_000 });
 
// defineEndpoint is async — await it (top-level await in an ES module) before subscribing
await queryCache.defineEndpoint('products', async () => {
  const res = await fetch('/api/products');
  if (!res.ok) throw new Error(`HTTP ${res.status}`);
  return res.json() as Promise<Product[]>;
});
 
export class ProductModel extends BaseModel<Product[], never> {
  readonly events = new EventEmitter<ProductModelEvents>();
 
  constructor() {
    super({});
 
    // Forward QueryCore state to BaseModel signals
    queryCache.subscribe('products', (state) => {
      this.setLoading(state.isLoading);
      if (state.error) this.setError(state.error);
      if (state.data) this.setData(state.data);
    });
  }
 
  async fetchAll(): Promise<void> {
    await queryCache.refetch('products');
  }
 
  async delete(productId: string): Promise<void> {
    const current = this.data$.peek() ?? [];
    const product = current.find((p) => p.id === productId);
    if (!product) throw new Error(`Product ${productId} not found`);
 
    const res = await fetch(`/api/products/${productId}`, { method: 'DELETE' });
    if (!res.ok) throw new Error(`Delete failed: HTTP ${res.status}`);
 
    // Update local data now that the server confirmed the delete
    this.setData(current.filter((p) => p.id !== productId));
    await queryCache.invalidate('products');
 
    // Emit typed model event
    this.events.emit('deleted', { productId, name: product.name });
 
    // Emit to app-wide event bus for cross-feature communication
    appBus.emit('product:deleted', { productId, name: product.name });
  }
 
  dispose(): void {
    this.events.removeAllListeners();
    super.dispose();
  }
}

The Model is completely ignorant of the UI. It doesn't know React exists. It handles HTTP, caching, and business events.


Step 5: The ViewModel

The ViewModel derives what the View needs from the Model. It composes UI patterns and exposes interaction requests.

// src/features/products/ProductViewModel.ts
import { Command } from '@web-loom/mvvm-core';
import { ActiveAwareViewModel } from '@web-loom/mvvm-patterns';
import { ConfirmationRequest, NotificationRequest } from '@web-loom/mvvm-patterns';
import { signal, computed } from '@web-loom/signals-core';
import { ProductModel, Product } from './ProductModel';
 
export class ProductViewModel extends ActiveAwareViewModel<ProductModel> {
  // --- Signals for the View ---
  // (data$, isLoading$ and error$ are inherited from BaseViewModel)
  readonly products$ = computed(() => this.data$.get() ?? []);
 
  // --- Search signal (reactive, local UI state) ---
  private readonly _search = signal('');
  readonly search = this._search;
  readonly filteredProducts = computed(() => {
    const query = this._search.get().toLowerCase();
    const all = this.data$.get() ?? [];
    if (!query) return all;
    return all.filter((p) => p.name.toLowerCase().includes(query) || p.category.toLowerCase().includes(query));
  });
 
  // --- Selection (local UI state) ---
  // ui-patterns' createMasterDetail has no action to replace its item list,
  // so for a list that reloads and filters, a pair of signals is simpler.
  private readonly _selectedId = signal<string | null>(null);
  readonly selectedId$ = this._selectedId.asReadonly();
  readonly selectedProduct$ = computed(
    () => (this.data$.get() ?? []).find((p) => p.id === this._selectedId.get()) ?? null,
  );
 
  // --- Interaction Requests ---
  readonly confirmDelete = new ConfirmationRequest();
  readonly notifyDeleted = new NotificationRequest();
 
  // --- Commands ---
  readonly loadCommand = this.registerCommand(new Command(() => this.model.fetchAll()));
  readonly deleteCommand = this.registerCommand(
    new Command(async (productId: string) => {
      const product = (this.data$.peek() ?? []).find((p) => p.id === productId);
      if (!product) return;
 
      const response = await this.confirmDelete.raiseAsync({
        title: 'Delete Product',
        content: `Remove "${product.name}" from the catalogue? This cannot be undone.`,
        confirmText: 'Delete',
        cancelText: 'Cancel',
      });
 
      if (!response.confirmed) return;
 
      await this.model.delete(productId);
 
      this.notifyDeleted.raise({
        content: `"${product.name}" has been removed.`,
      });
    }),
  );
 
  constructor(model: ProductModel) {
    super(model);
  }
 
  protected onIsActiveChanged(isActive: boolean, _wasActive: boolean): void {
    if (isActive) {
      // Load only if we have no data yet (cache will prevent re-fetching)
      if (!this.data$.peek()) {
        this.loadCommand.execute();
      }
    }
  }
 
  setSearch(query: string): void {
    this._search.set(query);
  }
 
  selectProduct(id: string | null): void {
    this._selectedId.set(id);
  }
 
  dispose(): void {
    this.confirmDelete.dispose();
    this.notifyDeleted.dispose();
    super.dispose();
  }
}

The ViewModel has zero framework imports. It uses three @web-loom packages but nothing from React, Vue, or Angular. Every piece of View logic — what's selected, what's filtered, what dialog to show, whether to load — is handled here.


Step 6: The Command Palette

Wire the command palette at the feature level. Commands live here because they invoke ViewModel actions.

// src/features/products/useProductPalette.ts (React hook)
import { useMemo, useEffect } from 'react';
import { createCommandPalette } from '@web-loom/ui-patterns';
import { uiStore } from '../../stores/ui-store';
import { ProductViewModel } from './ProductViewModel';
 
export function useProductPalette(vm: ProductViewModel) {
  const palette = useMemo(
    () =>
      createCommandPalette({
        commands: [
          {
            id: 'reload',
            label: 'Reload Products',
            category: 'Products',
            shortcut: 'R',
            action: () => vm.loadCommand.execute(),
          },
          {
            id: 'toggle-theme',
            label: 'Toggle Dark Mode',
            category: 'Appearance',
            keywords: ['dark', 'light', 'theme'],
            action: () => {
              const { theme } = uiStore.getState();
              uiStore.actions.setTheme(theme === 'dark' ? 'light' : 'dark');
            },
          },
          {
            id: 'toggle-sidebar',
            label: 'Toggle Sidebar',
            category: 'Layout',
            action: () => uiStore.actions.toggleSidebar(),
          },
        ],
        onOpen: () => uiStore.actions.openPalette(),
        onClose: () => uiStore.actions.closePalette(),
      }),
    [vm],
  );
 
  // Open palette on Ctrl+K
  useEffect(() => {
    const handler = (e: KeyboardEvent) => {
      if ((e.ctrlKey || e.metaKey) && e.key === 'k') {
        e.preventDefault();
        palette.actions.open();
      }
    };
    window.addEventListener('keydown', handler);
    return () => window.removeEventListener('keydown', handler);
  }, [palette]);
 
  return palette;
}

Step 7: The React View

The View is thin. It subscribes, renders, and calls commands.

// src/features/products/ProductBrowser.tsx
import { useCallback, useMemo, useEffect, useState, useSyncExternalStore } from 'react';
import type { ReadonlySignal } from '@web-loom/signals-core';
import { ProductViewModel } from './ProductViewModel';
import { ProductModel, Product } from './ProductModel';
import { useProductPalette } from './useProductPalette';
import { uiStore } from '../../stores/ui-store';
import type { IConfirmation, INotification } from '@web-loom/mvvm-patterns';
 
// --- Bridge a Web Loom signal to React ---
function useSignal<T>(sig: ReadonlySignal<T>): T {
  const subscribe = useCallback((onChange: () => void) => sig.subscribe(onChange), [sig]);
  const getSnapshot = useCallback(() => sig.get(), [sig]);
  return useSyncExternalStore(subscribe, getSnapshot, getSnapshot);
}
 
export function ProductBrowser() {
  const model = useMemo(() => new ProductModel(), []);
  const vm = useMemo(() => new ProductViewModel(model), [model]);
  const palette = useProductPalette(vm);
 
  // Activate/deactivate with component lifecycle
  useEffect(() => {
    vm.activate();
    return () => {
      vm.deactivate();
      vm.dispose();
      model.dispose();
    };
  }, [vm, model]);
 
  // Read ViewModel signals
  const products = useSignal(vm.products$);
  const isLoading = useSignal(vm.isLoading$);
  const error = useSignal(vm.error$);
  const filteredProducts = useSignal(vm.filteredProducts);
  const isDeleting = useSignal(vm.deleteCommand.isExecuting$);
  const search = useSignal(vm.search);
  const selectedId = useSignal(vm.selectedId$);
  const selectedProduct = useSignal(vm.selectedProduct$);
 
  // UI store
  const uiState = useSyncExternalStore(
    (cb) => {
      const unsub = uiStore.subscribe(cb);
      return unsub;
    },
    () => uiStore.getState(),
  );
 
  // Interaction request state
  const [confirmation, setConfirmation] = useState<{
    context: IConfirmation;
    callback: (r: IConfirmation) => void;
  } | null>(null);
  const [notification, setNotification] = useState<string | null>(null);
 
  useEffect(() => {
    const unsubscribeConfirm = vm.confirmDelete.requested$.subscribe(setConfirmation);
    const unsubscribeNotify = vm.notifyDeleted.requested$.subscribe((event) => {
      setNotification(event.context.content);
      setTimeout(() => setNotification(null), 3000);
      event.callback(event.context);
    });
    return () => {
      unsubscribeConfirm();
      unsubscribeNotify();
    };
  }, [vm]);
 
  if (isLoading && !products.length) return <div>Loading…</div>;
  if (error) return <div>Error: {error.message}</div>;
 
  return (
    <div style={{ display: 'flex', background: 'var(--color-surface)', minHeight: '100vh' }}>
      {/* Sidebar */}
      {uiState.sidebarOpen && (
        <aside
          style={{
            width: '280px',
            borderRight: '1px solid var(--color-border)',
            padding: 'var(--space-4)',
          }}
        >
          <input
            value={search}
            onChange={(e) => vm.setSearch(e.target.value)}
            placeholder="Search products…"
            style={{
              width: '100%',
              padding: 'var(--space-2) var(--space-3)',
              border: '1px solid var(--color-border)',
              borderRadius: 'var(--radius-md)',
              background: 'var(--color-surface-subtle)',
              color: 'var(--color-text-primary)',
            }}
          />
 
          <ul style={{ marginTop: 'var(--space-4)', listStyle: 'none', padding: 0 }}>
            {filteredProducts.map((product) => (
              <li
                key={product.id}
                onClick={() => vm.selectProduct(product.id)}
                style={{
                  padding: 'var(--space-3)',
                  borderRadius: 'var(--radius-sm)',
                  cursor: 'pointer',
                  background:
                    selectedId === product.id
                      ? 'var(--color-surface-raised)'
                      : 'transparent',
                  color: 'var(--color-text-primary)',
                }}
              >
                <div style={{ fontWeight: 500 }}>{product.name}</div>
                <div style={{ fontSize: '0.875rem', color: 'var(--color-text-secondary)' }}>
                  {product.category} · ${product.price.toFixed(2)}
                </div>
              </li>
            ))}
          </ul>
        </aside>
      )}
 
      {/* Detail panel */}
      <main style={{ flex: 1, padding: 'var(--space-8)' }}>
        {selectedProduct ? (
          <ProductDetail
            product={selectedProduct}
            onDelete={() => vm.deleteCommand.execute(selectedProduct.id).catch(() => {})}
            isDeleting={isDeleting}
          />
        ) : (
          <div style={{ color: 'var(--color-text-secondary)' }}>Select a product from the list.</div>
        )}
      </main>
 
      {/* Command Palette */}
      {uiState.commandPaletteOpen && <CommandPaletteOverlay palette={palette} />}
 
      {/* Confirmation Dialog */}
      {confirmation && (
        <ConfirmationDialog
          title={confirmation.context.title}
          message={confirmation.context.content}
          confirmText={confirmation.context.confirmText ?? 'Confirm'}
          cancelText={confirmation.context.cancelText ?? 'Cancel'}
          onConfirm={() => {
            confirmation.callback({ ...confirmation.context, confirmed: true });
            setConfirmation(null);
          }}
          onCancel={() => {
            confirmation.callback({ ...confirmation.context, confirmed: false });
            setConfirmation(null);
          }}
        />
      )}
 
      {/* Toast Notification */}
      {notification && (
        <div
          style={{
            position: 'fixed',
            bottom: 'var(--space-6)',
            right: 'var(--space-6)',
            padding: 'var(--space-3) var(--space-4)',
            background: 'var(--color-success)',
            color: 'var(--color-text-inverse)',
            borderRadius: 'var(--radius-md)',
          }}
        >
          {notification}
        </div>
      )}
    </div>
  );
}

Step 8: Cross-Feature Listening

Another feature — say, the cart — can listen to product deletions through the event bus without importing anything from the products feature:

// src/features/cart/CartModel.ts
import { appBus } from '../../infrastructure/bus';
 
class CartModel extends BaseModel<CartState, never> {
  constructor() {
    super({});
 
    // Remove deleted products from active carts
    appBus.on('product:deleted', ({ productId }) => {
      const items = this.data$.peek()?.items ?? [];
      const updated = items.filter((item) => item.productId !== productId);
      this.setData({ ...this.data$.peek(), items: updated });
    });
  }
}

The cart knows nothing about how products are displayed. The products feature knows nothing about the cart. They share only the event definition in bus.ts.


What Just Happened

Let's inventory what each package contributed:

| Package | Role in this feature | | ------------------------------ | ------------------------------------------------------------------------------------------------------------------------- | | @web-loom/mvvm-core | BaseModel (ProductModel), BaseViewModel via ActiveAwareViewModel (ProductViewModel), Command (loadCommand, deleteCommand) | | @web-loom/query-core | QueryCore caches product list, serves from cache on tab re-visit, auto-refreshes after 5 minutes | | @web-loom/event-emitter-core | EventEmitter<ProductModelEvents> on the Model for internal typed events | | @web-loom/event-bus-core | appBus carries product:deleted to the cart feature without coupling | | @web-loom/store-core | uiStore persists theme and sidebar state across sessions | | @web-loom/ui-patterns | createCommandPalette provides Ctrl+K functionality | | @web-loom/mvvm-patterns | ConfirmationRequest decouples delete dialog from ViewModel; ActiveAwareViewModel pauses work when tab is inactive | | @web-loom/design-core | Token value types (ColorValue, SpacingValue, …) describe the CSS variable layer for theming | | @web-loom/signals-core | signal + computed power the search filter and selection | | @web-loom/ui-core | (Available for headless form behaviour on search field, table sorting — not shown in this example but a natural addition) |

Notice what the View layer does not contain:

  • No business logic
  • No fetch calls
  • No caching decisions
  • No event bus subscriptions for cross-feature concerns
  • No knowledge of what other features exist
  • No state management logic beyond subscribing and rendering

It renders. It calls commands. It handles interaction request callbacks. That's it.


Testing the Feature

Because the ViewModel and Model are framework-agnostic classes, testing the entire business logic requires no component rendering, no act(), no testing-library:

import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { ProductModel } from './ProductModel';
import { ProductViewModel } from './ProductViewModel';
 
describe('Product delete flow', () => {
  let model: ProductModel;
  let vm: ProductViewModel;
 
  beforeEach(() => {
    model = new ProductModel();
    vm = new ProductViewModel(model);
 
    // Seed the model with test data
    model['setData']([{ id: 'p1', name: 'Laptop', category: 'Electronics', price: 999, stock: 5, imageUrl: '' }]);
  });
 
  afterEach(() => {
    vm.dispose();
    model.dispose();
  });
 
  it('does not delete when user cancels', async () => {
    const deleteSpy = vi.spyOn(model, 'delete');
 
    vm.confirmDelete.requested$.subscribe((event) => {
      event.callback({ ...event.context, confirmed: false });
    });
 
    await vm.deleteCommand.execute('p1');
 
    expect(deleteSpy).not.toHaveBeenCalled();
  });
 
  it('deletes and emits notification when confirmed', async () => {
    vi.spyOn(model, 'delete').mockResolvedValue(undefined);
    const notificationMessages: string[] = [];
 
    vm.notifyDeleted.requested$.subscribe((event) => {
      notificationMessages.push(event.context.content);
      event.callback(event.context);
    });
 
    vm.confirmDelete.requested$.subscribe((event) => {
      event.callback({ ...event.context, confirmed: true });
    });
 
    await vm.deleteCommand.execute('p1');
 
    expect(model.delete).toHaveBeenCalledWith('p1');
    expect(notificationMessages).toContain('"Laptop" has been removed.');
  });
 
  it('filters products by search query', () => {
    model['setData']([
      { id: 'p1', name: 'Laptop', category: 'Electronics', price: 999, stock: 5, imageUrl: '' },
      { id: 'p2', name: 'Headphones', category: 'Electronics', price: 79, stock: 10, imageUrl: '' },
      { id: 'p3', name: 'Notebook', category: 'Stationery', price: 5, stock: 50, imageUrl: '' },
    ]);
 
    vm.setSearch('note');
    const filtered = vm.filteredProducts.get();
 
    expect(filtered).toHaveLength(1);
    expect(filtered[0].name).toBe('Notebook');
  });
});

The entire delete confirmation flow — with its async Promise chain and interaction request handshake — tested in a plain Vitest file. No browser. No React renderer. About 65 lines.


The Architecture Holds

What makes this architecture work isn't any individual package. It's the discipline of keeping concerns in their correct layers.

The design layer (design-core) establishes the visual contract once. Every component uses it through CSS variables — no component owns its own colour values.

The infrastructure layer (event-bus-core, store-core, query-core) handles the mechanics of state persistence, caching, and cross-feature communication. None of this is business logic.

The model layer (mvvm-core BaseModel, event-emitter-core) owns the data. It has no framework imports. It could run in a Node.js script.

The view-model layer (mvvm-core BaseViewModel, ui-patterns, signals-core, mvvm-patterns) derives what the View needs. It has no framework imports. It's a class.

The view layer (React in this example) subscribes to everything above it. It renders. When the user acts, it calls a command. When an interaction request arrives, it handles it and fires the callback. That's the full extent of its job.

If the team decides to migrate from React to Vue next year, the migration scope is the View layer. Everything else is unchanged. The ViewModels are the same. The Models are the same. The design tokens are the same. The event bus is the same. The cache is the same.

This is the promise of the architecture, stated plainly: when the framework changes — and frameworks change — you rewrite the 20% that was always framework-specific anyway. The 80% that represents your application's actual value survives.


Where Things Stand

At the time of this writing, eleven @web-loom packages are published to npm:

  • @web-loom/mvvm-core
  • @web-loom/signals-core
  • @web-loom/event-emitter-core
  • @web-loom/event-bus-core
  • @web-loom/store-core
  • @web-loom/query-core
  • @web-loom/forms-core
  • @web-loom/ui-core
  • @web-loom/ui-patterns
  • @web-loom/design-core
  • @web-loom/mcp-server

The create-web-loom starter CLI is published too (npm create web-loom@latest).

In progress: @web-loom/mvvm-patterns, the template-core templating packages, HTTP client, storage abstraction, router integration, i18n, notifications, error handling, platform detection. Framework adapters for forms and media live in the sibling web-loom-extensions repo.

The monorepo has demo applications for React, Angular, Vue, Solid, Lit, Marko, vanilla JavaScript, and React Native — all sharing the same ViewModels and Models, only differing in the View layer.


An Honest Closing

MVVM is not a new idea. It was worked out in the WPF era and has been standard on Android, iOS, and .NET for a decade. The web largely skipped it because React's component model was new and exciting and seemed to solve the same problems in a different way. It solved some of them. It left others — portability, testability, separation of concerns — to be rediscovered by each team individually.

Web Loom is a bet that the discipline is worth bringing back, formalised in TypeScript, and expressed through small packages rather than a monolithic framework. Whether that bet pays off depends on whether the problems it addresses are real enough to warrant the added structure.

If you've ever rewritten a feature because a framework updated, if you've ever seen business logic scattered across thirty custom hooks, if you've ever written a test that required rendering a component just to verify that a fetch was called — then you know the problems are real.

These packages are open. The monorepo is public. Feedback, questions, and criticism are all welcome.


This article concludes the Web Loom series. Links to all articles in the series:

  1. @web-loom/mvvm-core — The Architecture Layer the Web Forgot
  2. @web-loom/signals-core — Reactive State Without the Framework Tax
  3. @web-loom/event-emitter-core — Typed Events for the Modern Web
  4. @web-loom/event-bus-core — Decoupling Features With Typed Pub/Sub
  5. @web-loom/store-core — UI State Belongs Somewhere Specific
  6. @web-loom/query-core — Data Fetching With a Memory
  7. @web-loom/ui-core — The Behaviour Layer Below Your Components
  8. @web-loom/ui-patterns — When Behaviours Become Patterns
  9. @web-loom/design-core — Design Tokens as the Contract Between Design and Code
  10. @web-loom/mvvm-patterns — The Conversations ViewModels Need to Have
  11. Putting It All Together — Building a Complete Feature With Web Loom
Web Loom logo
Copyright © Web Loom. All rights reserved.