signals

js/signals.ts

fino:signals — retained reactive values for runtime read models.

Signals model state: the latest value is retained, subscribers are notified when that value changes, and Object.is skips redundant writes. They are a good fit for UI render dependencies, queue statistics, run status, counters, and other "what is true now" views. They are not an event log; streams, topics, and async iterators remain the right surface when every intermediate value matters.

Design

Writable Signal instances are owned by producers. Public APIs should expose ReadonlySignal when consumers must observe but not update the value. batch() coalesces synchronous writes into one notification pass, while computed() and effect() use observeReads() to track whichever signals were read on the latest run.

lazy() and fromIterable() are cold bridges: upstream work starts only when the first subscriber attaches and is disposed after the last subscriber leaves. The current value remains readable even while the producer is cold.

import { computed, createSignal, effect } from 'fino:signals';

const count = createSignal(0);
const doubled = computed(() => count.get() * 2);

const stop = effect(() => {
  console.log(doubled.get());
});

count.set(2);
stop();

Types

type SignalSubscriber<T> = (value: T, previous: T) => void

Callback invoked after a signal value changes.

The callback receives the current value and the previous value. Batched writes call subscribers once with the value before the first write and the final value after the outermost batch completes.

type SignalSetter<T> = T | ((value: T) => T)

Value or updater accepted by Signal.set().

Interfaces

interface ReadonlySignal<T> {

Read-only signal handle for consumers.

A ReadonlySignal exposes the current value and change notifications but does not allow callers to write. Producers should prefer this shape for public runtime read models.

Methods

get(): T

Return the current retained value.

subscribe(subscriber: SignalSubscriber<T>): () => void

Subscribe to future value changes.

The callback is not called immediately. The returned function removes the subscription and is safe to call more than once.

interface ObservedReads<T> {

Result returned by observeReads().

Properties

value: T

Value returned by the observed callback.

signals: ReadonlySignal < unknown > []

Unique signals read while the callback ran, in first-read order.

Functions

function batch<T>(fn: () => T): T

Run multiple signal writes as one notification pass.

Subscribers are called after the outermost batch completes, and each changed signal notifies at most once with its final value.

function createSignal<T>(initial: T): Signal<T>

Create a writable signal with explicit get, set, and subscribe methods.

function observeReads<T>(fn: () => T): ObservedReads<T>

Run fn and return the signals read during that run.

This is the low-level hook used by renderers and reactive helpers. Nested calls are isolated, and duplicate reads of the same signal are reported once.

function computed<T>(fn: () => T): ReadonlySignal<T>

Create a read-only signal derived from other signals.

The derivation runs immediately, then re-runs whenever any signal read during the latest run changes. Conditional dependencies are re-tracked each time.

function effect(fn: () => void): () => void

Run a side effect now and whenever its read dependencies change.

The returned function disposes the current dependencies and prevents future re-runs. Dependencies are re-tracked on every execution.

function lazy<T>(initial: T, start: (set: (value: T) => void) => () => void): ReadonlySignal<T>

Create a cold read-only signal.

start is called when the first subscriber attaches. It receives a setter for publishing retained values and returns a cleanup callback, which runs after the last subscriber unsubscribes.

function fromIterable< T, S >(src: AsyncIterable<T>, fold: (acc: S, item: T) => S, initial: S): ReadonlySignal<S>

Fold an async iterable into a cold retained signal.

The iterable is consumed only while the signal has subscribers. On teardown, the active iterator's return() method is called when present so upstream subscriptions and readers can release resources.

Classes

class Signal<T> implements ReadonlySignal<T> {

Explicit mutable reactive value.

Use get() to read the retained value, set() to replace or derive the next value, and subscribe() to observe later changes. Redundant writes are skipped with Object.is.

Constructors

constructor(initial: T)

Methods

get(): T

Return the current signal value and record the read when observed.

set(next: SignalSetter<T>): void

Replace the value or derive the next value from the current one.

Subscribers are skipped when Object.is(previous, next) is true.

subscribe(subscriber: SignalSubscriber<T>): () => void

Subscribe to value changes.

The returned function removes the subscriber. Subscriptions do not fire immediately; they only observe subsequent writes.