@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.
Installation
pnpm add @xmachines/play-signalsOverview
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()); // 0count.set(5);console.log(count.get()); // 5Signal.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 changescount.set(3); // → logs "count changed: 3" once
// Stop watchingcleanup();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 notificationCustom 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 dependentsperson.set({ name: "Alice", age: 30 });API Summary
| Export | Kind | Description |
|---|---|---|
Signal | namespace | Full TC39 Signals namespace (State, Computed, subtle.Watcher) re-exported from signal-polyfill |
watchSignal(signal, onValue) | function | The memory-safe subscription helper. It returns a cleanup function |
SignalState<T> | interface | Shape of Signal.State<T> (.get(), .set()) |
SignalComputed<T> | interface | Shape of Signal.Computed<T> (.get()) |
SignalWatcher | interface | Shape of Signal.subtle.Watcher (.watch(), .unwatch(), .getPending()) |
SignalOptions<T> | interface | The options object for the Signal.State constructor (equals?) |
ComputedOptions<T> | interface | The options object for the Signal.Computed constructor (equals?) |
WatcherNotify | type | The callback signature of the Signal.subtle.Watcher notify function |
Testing
Run tests for this package in isolation:
# From this package directorypnpm test
# Watch modepnpm test -- --watchFrom the monorepo root:
# Run tests for this packagepnpm --filter @xmachines/play-signals test
# Run with coverage (lines ≥ 90 %, functions ≥ 90 %, branches ≥ 85 %, statements ≥ 90 %)pnpm run test:coverageRequirements
- 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 signalconst count = new Signal.State(0);
// Create a computed signalconst doubled = new Signal.Computed(() => count.get() * 2);
// Observe the changesconst watcher = new Signal.subtle.Watcher(() => { console.log("Count:", count.get(), "Doubled:", doubled.get());});watcher.watch(count);
count.set(5); // Logs: Count: 5 Doubled: 10See
- Play RFC - invariant INV-05
- TC39 Signals Proposal
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.