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()andcomputed()accept anequalsoption 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-coreQuick 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 fnWritableSignal<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 fnComputedOptions<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 cleanupEffectHandle
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)); // falseRxJS 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 rxjsimport { 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 observablefromObservable 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
privateinside ViewModels and Models; exposeasReadonly()surfaces to consumers. - Prefer
computedovereffectfor derived state — computed values are lazy and memoized; effects run eagerly. - Always
dispose()effects in ViewModel teardown methods to avoid memory leaks. - Use
batchwhen a single action updates more than one signal to avoid intermediate renders. - Use
untrackedinside effects when you need to read a signal as a snapshot without subscribing to its changes. - Prefer
peek()overget()for reading a signal's current value inside an action that should not register a dependency. - Add
debugNameto signals during development to make DevTools output easier to read.