Web Loom logoWeb.loom
Published PackagesSignals Core

Signals Core

Framework-agnostic reactive signals with computed values and effects.

Signals Core

@web-loom/signals-core is a zero-dependency, framework-agnostic reactive signals library. It provides fine-grained reactivity through signals, derived computed values, and side effects — making it a natural fit for the ViewModel layer in MVVM applications.

Overview

Unlike RxJS Observables, signals are synchronous, pull-based values with automatic dependency tracking. You read a signal inside a computed or effect and the library records that dependency automatically — no manual subscription management required.

Key characteristics:

  • Zero dependencies — no RxJS, no external runtime
  • Lazy computed — derived values only recompute on get() after a dependency changes
  • Dynamic dependency tracking — only signals read during a computation are tracked; stale deps are cleared automatically
  • Custom equality — both signal() and computed() accept an equals option to suppress unnecessary notifications
  • Effect cleanup — returning a function from an effect registers it as cleanup, called before each rerun and on dispose()
  • Batching — nested batch() calls are safe; the flush happens once at the outermost boundary

Installation

npm install @web-loom/signals-core

Quick Start

import { signal, computed, effect, batch } from '@web-loom/signals-core';
 
// Writable signal
const count = signal(0);
 
// Derived computed (lazy, memoized)
const doubled = computed(() => count.get() * 2);
 
// Side effect — runs immediately, reruns when dependencies change
const handle = effect(() => {
  console.log('count:', count.get(), 'doubled:', doubled.get());
  return () => console.log('cleanup');
});
 
count.set(5); // logs: cleanup  count: 5  doubled: 10
 
// Batch multiple updates into a single notification flush
batch(() => {
  count.set(10);
  count.set(20);
}); // effect runs once with final value
 
// Stop the effect
handle.dispose();

API

signal<T>(initial, options?)

Creates a writable reactive value.

const name = signal('Alice');
 
name.get();               // read — tracked inside computed/effect
name.peek();              // read without tracking (no dependency registered)
name.set('Bob');          // write — notifies subscribers if value changed
name.update(v => v + '!'); // update based on previous value
name.asReadonly();        // returns a ReadonlySignal view (hides set/update)
name.subscribe(fn);       // low-level subscription — returns unsubscribe fn

WritableSignal<T> interface

interface WritableSignal<T> {
  get(): T;
  peek(): T;
  set(next: T): void;
  update(fn: (prev: T) => T): void;
  asReadonly(): ReadonlySignal<T>;
  subscribe(fn: () => void): () => void;
}

ReadonlySignal<T> interface

interface ReadonlySignal<T> {
  get(): T;
  peek(): T;
  subscribe(fn: () => void): () => void;
}

SignalOptions<T>

interface SignalOptions<T> {
  /** Custom equality check. Defaults to Object.is. */
  equals?: (a: T, b: T) => boolean;
  debugName?: string;
}
signal(value, {
  equals: (a, b) => a.id === b.id, // custom equality — default is Object.is
  debugName: 'selectedItem',
});

computed<T>(derive, options?)

Creates a lazy, memoized derived value. Recomputes only when a dependency changes and get() is called. Computed<T> is an alias for ReadonlySignal<T>.

const greeting = computed(() => `Hello, ${name.get()}!`);
 
greeting.get();         // 'Hello, Bob!' — tracked read
greeting.peek();        // read without tracking
greeting.subscribe(fn); // subscribe — returns unsubscribe fn

ComputedOptions<T>

interface ComputedOptions<T> {
  /** Custom equality check for the derived value. Defaults to Object.is. */
  equals?: (a: T, b: T) => boolean;
  debugName?: string;
}

effect(fn, options?)

Runs fn immediately and reruns whenever any signal read inside fn changes. Returns an EffectHandle with a dispose() method.

If fn returns a function, that function is called as cleanup before each rerun and on final disposal.

const handle = effect(() => {
  document.title = `Count: ${count.get()}`;
  return () => { /* cleanup before next run */ };
});
 
handle.dispose(); // stop the effect, run final cleanup

[object Object]

interface EffectHandle {
  dispose(): void;
}

batch<T>(fn)

Defers all signal notifications until fn completes, coalescing multiple writes into a single flush. Returns the value returned by fn. Nested batches are supported.

const result = batch(() => {
  a.set(1);
  b.set(2);
  c.set(3);
  return 'done';
}); // subscribers notified once; result === 'done'

untracked<T>(fn)

Executes fn without registering any signal reads as dependencies. Use this inside computed or effect to read a signal without tracking it.

const a = signal(1);
const b = signal(10);
 
effect(() => {
  const val = a.get();                       // tracked — effect reruns when a changes
  const snapshot = untracked(() => b.get()); // NOT tracked — b changes won't rerun the effect
  console.log(val, snapshot);
});

flush()

Force-processes any pending batched notifications synchronously. Useful in adapters and tests.

flush();

isSignal / isWritableSignal

Type guards for duck-typing signal instances.

import { isSignal, isWritableSignal } from '@web-loom/signals-core';
 
isSignal(signal(0));           // true
isSignal(computed(() => 1));   // true
isSignal(42);                  // false
 
isWritableSignal(signal(0));              // true
isWritableSignal(signal(0).asReadonly()); // false
isWritableSignal(computed(() => 1));      // false

MVVM Patterns

Encapsulation in ViewModels

Use asReadonly() to expose state without allowing external writes — mirrors Angular's signal encapsulation pattern and enforces unidirectional data flow in MVVM.

class CounterViewModel {
  private _count = signal(0);
 
  // Public read-only interface — consumers cannot write
  readonly count = this._count.asReadonly();
  readonly doubled = computed(() => this._count.get() * 2);
  readonly isPositive = computed(() => this._count.get() > 0);
 
  increment() {
    this._count.update((v) => v + 1);
  }
 
  decrement() {
    this._count.update((v) => v - 1);
  }
 
  reset() {
    this._count.set(0);
  }
}

The ViewModel owns the writable signals privately. The View subscribes to the readonly surface and calls methods to trigger mutations — clean separation with no way to accidentally bypass business logic.


Command Pattern with Signals

Signals pair naturally with the Command pattern used in MVVM Core. Here is a signals-based command implementation that provides the same isExecuting, canExecute, and execute interface:

import { signal, computed } from '@web-loom/signals-core';
 
function createCommand<T>(
  execute: () => Promise<T>,
  canExecute?: () => boolean,
) {
  const _isExecuting = signal(false);
  const _error = signal<Error | null>(null);
 
  const isExecuting = _isExecuting.asReadonly();
  const error = _error.asReadonly();
  const canRun = computed(() => !_isExecuting.get() && (canExecute?.() ?? true));
 
  async function run(): Promise<T | undefined> {
    if (!canRun.get()) return;
    _isExecuting.set(true);
    _error.set(null);
    try {
      return await execute();
    } catch (err) {
      _error.set(err as Error);
    } finally {
      _isExecuting.set(false);
    }
  }
 
  return { isExecuting, canRun, error, execute: run };
}
class TaskViewModel {
  private _tasks = signal<Task[]>([]);
  private _filter = signal<'all' | 'done'>('all');
 
  readonly tasks = this._tasks.asReadonly();
  readonly visibleTasks = computed(() => {
    const filter = this._filter.get();
    return filter === 'done'
      ? this._tasks.get().filter((t) => t.done)
      : this._tasks.get();
  });
 
  readonly fetchCommand = createCommand(async () => {
    const data = await api.getTasks();
    this._tasks.set(data);
  });
 
  setFilter(filter: 'all' | 'done') {
    this._filter.set(filter);
  }
}

Model Layer with Signals

Models hold the canonical data and expose it through readonly signals. ViewModels derive presentation state from Model signals via computed.

// Model — owns data, handles fetching/persistence
class TaskModel {
  private _items = signal<Task[]>([]);
  private _isLoading = signal(false);
  private _error = signal<string | null>(null);
 
  readonly items = this._items.asReadonly();
  readonly isLoading = this._isLoading.asReadonly();
  readonly error = this._error.asReadonly();
 
  async fetchAll() {
    this._isLoading.set(true);
    this._error.set(null);
    try {
      this._items.set(await api.getTasks());
    } catch (e) {
      this._error.set((e as Error).message);
    } finally {
      this._isLoading.set(false);
    }
  }
 
  async add(title: string) {
    const task = await api.createTask(title);
    this._items.update((prev) => [...prev, task]);
  }
}
 
// ViewModel — derives presentation state from the Model
class TaskListViewModel {
  private model = new TaskModel();
  private _search = signal('');
 
  readonly search = this._search.asReadonly();
  readonly isLoading = this.model.isLoading;
  readonly error = this.model.error;
 
  readonly filteredTasks = computed(() => {
    const query = this._search.get().toLowerCase();
    return this.model.items.get().filter((t) =>
      t.title.toLowerCase().includes(query),
    );
  });
 
  readonly taskCount = computed(() => this.filteredTasks.get().length);
 
  setSearch(value: string) {
    this._search.set(value);
  }
 
  load() {
    return this.model.fetchAll();
  }
 
  addTask(title: string) {
    return this.model.add(title);
  }
}

Batching State Updates

Use batch when a single user action updates multiple signals to prevent intermediate renders:

class FormViewModel {
  private _name = signal('');
  private _email = signal('');
  private _isDirty = signal(false);
 
  readonly name = this._name.asReadonly();
  readonly email = this._email.asReadonly();
  readonly isDirty = this._isDirty.asReadonly();
 
  reset() {
    // Subscribers are notified once after all three updates
    batch(() => {
      this._name.set('');
      this._email.set('');
      this._isDirty.set(false);
    });
  }
}

Effects for Side Effects

Use effect inside a ViewModel to react to signal changes without polling — for example, auto-saving or syncing to storage:

class PreferencesViewModel {
  private _theme = signal<'light' | 'dark'>('light');
  private _effectHandle: EffectHandle;
 
  readonly theme = this._theme.asReadonly();
 
  constructor() {
    // Persist preference whenever it changes
    this._effectHandle = effect(() => {
      localStorage.setItem('theme', this._theme.get());
    });
  }
 
  setTheme(theme: 'light' | 'dark') {
    this._theme.set(theme);
  }
 
  dispose() {
    this._effectHandle.dispose();
  }
}

Always call dispose() on effects when the ViewModel is torn down to prevent memory leaks and stale side effects.


Framework Integration

signals-core ships with no framework dependencies. Bind signals to framework reactivity by subscribing in the appropriate lifecycle hook.

React

import { useSyncExternalStore } from 'react';
import type { ReadonlySignal } from '@web-loom/signals-core';
 
function useSignal<T>(signal: ReadonlySignal<T>): T {
  return useSyncExternalStore(
    (notify) => signal.subscribe(notify),
    () => signal.get(),
    () => signal.get(),
  );
}
function TaskList() {
  const vm = useMemo(() => new TaskListViewModel(), []);
 
  useEffect(() => {
    vm.load();
    return () => vm.dispose?.();
  }, [vm]);
 
  const tasks = useSignal(vm.filteredTasks);
  const isLoading = useSignal(vm.isLoading);
  const search = useSignal(vm.search);
 
  return (
    <div>
      <input
        value={search}
        onChange={(e) => vm.setSearch(e.target.value)}
        placeholder="Search tasks…"
      />
      {isLoading ? (
        <p>Loading…</p>
      ) : (
        <ul>
          {tasks.map((task) => (
            <li key={task.id}>{task.title}</li>
          ))}
        </ul>
      )}
    </div>
  );
}

useSyncExternalStore is the recommended React 18+ API for subscribing to external stores. The signal.subscribe callback fires synchronously during the same microtask as the signal write, so React can batch the re-render efficiently.

Vue 3

import { ref, onMounted, onUnmounted, readonly } from 'vue';
import type { ReadonlySignal } from '@web-loom/signals-core';
 
function useSignal<T>(signal: ReadonlySignal<T>) {
  const state = ref<T>(signal.get());
  let unsubscribe: (() => void) | null = null;
 
  onMounted(() => {
    unsubscribe = signal.subscribe(() => {
      state.value = signal.get();
    });
  });
 
  onUnmounted(() => unsubscribe?.());
 
  return readonly(state);
}
<script setup lang="ts">
import { onMounted, onUnmounted } from 'vue';
import { TaskListViewModel } from './task-list-vm';
 
const vm = new TaskListViewModel();
const tasks = useSignal(vm.filteredTasks);
const isLoading = useSignal(vm.isLoading);
 
onMounted(() => vm.load());
onUnmounted(() => vm.dispose?.());
</script>
 
<template>
  <ul v-if="!isLoading">
    <li v-for="task in tasks" :key="task.id">{{ task.title }}</li>
  </ul>
</template>

Angular

Angular's Signal primitive is compatible in spirit with signals-core. To bridge the two, convert a ReadonlySignal into a writable Angular signal updated via subscription:

import { Component, signal, OnInit, OnDestroy } from '@angular/core';
import { TaskListViewModel } from './task-list-vm';
 
@Component({
  selector: 'app-task-list',
  template: `
    <ul>
      @for (task of tasks(); track task.id) {
        <li>{{ task.title }}</li>
      }
    </ul>
  `,
})
export class TaskListComponent implements OnInit, OnDestroy {
  private vm = new TaskListViewModel();
 
  tasks = signal<Task[]>([]);
  isLoading = signal(false);
 
  private unsubscribers: Array<() => void> = [];
 
  ngOnInit() {
    this.vm.load();
 
    this.unsubscribers.push(
      this.vm.filteredTasks.subscribe(() => {
        this.tasks.set(this.vm.filteredTasks.get());
      }),
      this.vm.isLoading.subscribe(() => {
        this.isLoading.set(this.vm.isLoading.get());
      }),
    );
  }
 
  ngOnDestroy() {
    this.unsubscribers.forEach((fn) => fn());
    this.vm.dispose?.();
  }
}

Vanilla JavaScript

In plain JS, subscribe directly and update the DOM:

import { signal, computed, effect } from '@web-loom/signals-core';
 
const vm = new CounterViewModel();
const countEl = document.getElementById('count')!;
 
effect(() => {
  countEl.textContent = String(vm.count.get());
});
 
document.getElementById('inc')!.addEventListener('click', () => vm.increment());
document.getElementById('dec')!.addEventListener('click', () => vm.decrement());

Testing

Because signals are synchronous, testing ViewModels is straightforward — no async scheduling or marble diagrams required.

import { describe, it, expect, vi } from 'vitest';
import { flush } from '@web-loom/signals-core';
import { CounterViewModel } from './counter-vm';
 
describe('CounterViewModel', () => {
  it('increments count', () => {
    const vm = new CounterViewModel();
    expect(vm.count.get()).toBe(0);
    vm.increment();
    expect(vm.count.get()).toBe(1);
  });
 
  it('doubles count correctly', () => {
    const vm = new CounterViewModel();
    vm.increment();
    vm.increment();
    expect(vm.doubled.get()).toBe(4);
  });
 
  it('notifies subscribers on change', () => {
    const vm = new CounterViewModel();
    const spy = vi.fn();
    const unsub = vm.count.subscribe(spy);
 
    vm.increment();
    expect(spy).toHaveBeenCalledTimes(1);
 
    unsub();
    vm.increment(); // no more calls after unsubscribe
    expect(spy).toHaveBeenCalledTimes(1);
  });
 
  it('does not notify when value is unchanged', () => {
    const vm = new CounterViewModel();
    const spy = vi.fn();
    vm.count.subscribe(spy);
 
    vm.reset(); // already 0 → no change
    expect(spy).not.toHaveBeenCalled();
  });
});

Use flush() to force-process any pending batched notifications when testing code that uses batch:

it('batches updates', () => {
  const vm = new FormViewModel();
  const spy = vi.fn();
  vm.name.subscribe(spy);
 
  batch(() => {
    vm.setName('Alice');
    vm.setName('Bob');
  });
 
  flush(); // ensure notifications have propagated
  expect(spy).toHaveBeenCalledTimes(1);
  expect(vm.name.get()).toBe('Bob');
});

Use untracked in tests to read signal values without accidentally registering dependencies:

import { untracked } from '@web-loom/signals-core';
 
const snapshot = untracked(() => vm.filteredTasks.get());

TypeScript Types

All types are exported from the package root:

import type {
  ReadonlySignal,
  WritableSignal,
  SignalOptions,
  Equals,
  Computed,
  ComputedOptions,
  EffectHandle,
  EffectFn,
  CleanupFn,
  EffectOptions,
} from '@web-loom/signals-core';

Best Practices

  • Keep writable signals private inside ViewModels and Models; expose asReadonly() surfaces to consumers.
  • Prefer computed over effect for derived state — computed values are lazy and memoized; effects run eagerly.
  • Always dispose() effects in ViewModel teardown methods to avoid memory leaks.
  • Use batch when a single action updates more than one signal to avoid intermediate renders.
  • Use untracked inside effects when you need to read a signal as a snapshot without subscribing to its changes.
  • Prefer peek() over get() for reading a signal's current value inside an action that should not register a dependency.
  • Add debugName to signals during development to make DevTools output easier to read.

See Also

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