Skip to content

@xmachines/play-signals

API / @xmachines/play-signals

TC39 Signals polyfill for XMachines. It gives the Play Architecture fine-grained reactive state primitives. The primitives propagate state without a glitch and without a subscription.

License: MIT Version

Installation

Terminal window
pnpm add @xmachines/play-signals

Overview

This package wraps the signal-polyfill reference implementation of the TC39 Signals proposal (Stage 1). It re-exports the complete Signal namespace, and it adds the memory-safe watchSignal utility. The wrapper keeps the rest of the code away from a Stage 1 API that can still change.

Import every signal in the XMachines ecosystem from this package, not from signal-polyfill. One import point keeps each polyfill update and each API change in one place.

Usage

Signal.State — writable reactive state

import { Signal } from "@xmachines/play-signals";
const count = new Signal.State(0);
console.log(count.get()); // 0
count.set(5);
console.log(count.get()); // 5

Signal.Computed — lazy memoized derived values

import { Signal } from "@xmachines/play-signals";
const count = new Signal.State(0);
const doubled = new Signal.Computed(() => count.get() * 2);
console.log(doubled.get()); // 0 (computed on first access)
count.set(5);
console.log(doubled.get()); // 10 (recomputed because dependency changed)
console.log(doubled.get()); // 10 (memoized — no recomputation)

A computation tracks each signal that it reads. A dynamic branch is safe: the computation keeps only the signals of the current execution path as its dependencies.

watchSignal — memory-safe one-shot effect

Use watchSignal to subscribe to a Signal.State or to a Signal.Computed. The callback receives the value after each change. watchSignal groups the updates of one synchronous batch into a single microtask.

import { Signal, watchSignal } from "@xmachines/play-signals";
const count = new Signal.State(0);
const cleanup = watchSignal(count, (value) => {
console.log("count changed:", value);
});
count.set(1); // → logs "count changed: 1" (via microtask)
count.set(2); // coalesced with any rapid synchronous changes
count.set(3); // → logs "count changed: 3" once
// Stop watching
cleanup();

The cleanup function is idempotent. A second call is safe, and it does not throw.

Signal.subtle.Watcher — low-level multi-signal observation

Advanced code, such as a framework integration, can use the complete Signal.subtle.Watcher API:

import { Signal } from "@xmachines/play-signals";
const count = new Signal.State(0);
const doubled = new Signal.Computed(() => count.get() * 2);
const watcher = new Signal.subtle.Watcher(() => {
queueMicrotask(() => {
const pending = watcher.getPending();
console.log("signals changed:", pending.length);
watcher.watch(...pending); // re-arm for future changes
});
});
watcher.watch(count);
watcher.watch(doubled);
count.set(5); // schedules microtask notification

Custom equality

Both Signal.State and Signal.Computed accept an equals option. The option controls when a signal notifies its dependents:

import { Signal } from "@xmachines/play-signals";
import type { SignalOptions } from "@xmachines/play-signals";
const options: SignalOptions<{ name: string; age: number }> = {
equals: (a, b) => a.name === b.name && a.age === b.age,
};
const person = new Signal.State({ name: "Alice", age: 30 }, options);
// A structurally identical value does not notify the dependents
person.set({ name: "Alice", age: 30 });

API Summary

ExportKindDescription
SignalnamespaceFull TC39 Signals namespace (State, Computed, subtle.Watcher) re-exported from signal-polyfill
watchSignal(signal, onValue)functionThe memory-safe subscription helper. It returns a cleanup function
SignalState<T>interfaceShape of Signal.State<T> (.get(), .set())
SignalComputed<T>interfaceShape of Signal.Computed<T> (.get())
SignalWatcherinterfaceShape of Signal.subtle.Watcher (.watch(), .unwatch(), .getPending())
SignalOptions<T>interfaceThe options object for the Signal.State constructor (equals?)
ComputedOptions<T>interfaceThe options object for the Signal.Computed constructor (equals?)
WatcherNotifytypeThe callback signature of the Signal.subtle.Watcher notify function

Testing

Run tests for this package in isolation:

Terminal window
# From this package directory
pnpm test
# Watch mode
pnpm test -- --watch

From the monorepo root:

Terminal window
# Run tests for this package
pnpm --filter @xmachines/play-signals test
# Run with coverage (lines ≥ 90 %, functions ≥ 90 %, branches ≥ 85 %, statements ≥ 90 %)
pnpm run test:coverage

Requirements

  • Node.js >= 22.0.0
  • TypeScript 5.7+ (for a consumer that uses TypeScript)

License

MIT — see LICENSE.

The TC39 Signals polyfill of the XMachines Play Architecture

This package gives you the fine-grained reactive state primitives of the TC39 Signals proposal (Stage 1). It keeps the TC39 polyfill in one place, and it therefore protects the code from a change of the Stage 1 API.

Architectural context: the package implements Signal-Only Reactivity (INV-05). It gives the reactive primitives that carry the communication from the Actor to the infrastructure, without a subscription and without an event emitter. Every propagation of state in the Play Architecture uses a TC39 Signal, which tracks each dependency and updates without a glitch.

Example

The basic use of a signal

import { Signal } from "@xmachines/play-signals";
// Create a state signal
const count = new Signal.State(0);
// Create a computed signal
const doubled = new Signal.Computed(() => count.get() * 2);
// Observe the changes
const watcher = new Signal.subtle.Watcher(() => {
console.log("Count:", count.get(), "Doubled:", doubled.get());
});
watcher.watch(count);
count.set(5); // Logs: Count: 5 Doubled: 10

See

Remarks

Stage 1 status: TC39 Signals is at Stage 1 in the TC39 process now. This package uses the official signal-polyfill reference implementation. The code therefore stays separate from an API that can change while the proposal develops. Import every signal through this package, and the separation stays complete.

The reason for the separation: this dedicated package re-exports the polyfill. Therefore one place holds each new polyfill version and each change of the API, and no consuming package changes. This architectural decision protects the code from a change of the Stage 1 API.

Namespaces

Interfaces

Type Aliases

Functions