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()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 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)); // falseMVVM 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.