Web Loom logo
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 — and it's the actual reactive engine @web-loom/mvvm-core is built on: every data$/isLoading$/error$/canExecute$/isExecuting$ you'll see across the MVVM Core docs is a ReadonlySignal<T> from this package.

Overview

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. This is different from RxJS Observables (which mvvm-core used prior to mid-2026, and which this package can still bridge to via the optional @web-loom/signals-core/rxjs subpath): observables are push-based streams with no synchronous "current value" concept unless you reach for a BehaviorSubject, whereas every signal always has one, readable via .get()/.peek().

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

EffectHandle

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

RxJS Interop

The main @web-loom/signals-core entry point is zero-dependency — no RxJS. 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, import from the optional /rxjs subpath instead. It requires rxjs as an installed peer dependency.

npm install rxjs
import { signal } from '@web-loom/signals-core';
import { toObservable, fromObservable } from '@web-loom/signals-core/rxjs';
 
// Signal -> Observable (BehaviorSubject semantics: current value first, then every change)
const count = signal(0);
const count$ = toObservable(count);
count$.subscribe((v) => console.log(v)); // logs 0 immediately, then on every count.set(...)
 
// Observable -> Signal (holds `initial` until the source's first emission)
import { interval } from 'rxjs';
const ticks = fromObservable(interval(1000), 0);
ticks.get(); // 0, then 0, 1, 2, ... as the interval emits
ticks.dispose(); // unsubscribes from the source observable

fromObservable takes an optional third onError callback — signals have no error channel of their own, so observable errors are routed there instead of thrown.


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 logo
Copyright © Web Loom. All rights reserved.