@xmachines/play-signals
API / @xmachines/play-signals
TC39 Signals polyfill for XMachines — fine-grained reactive state primitives that enable glitch-free, subscription-free state propagation in the Play Architecture.
Part of the xmachines-js monorepo.
Installation
npm install @xmachines/play-signalsOverview
This package wraps the signal-polyfill reference implementation of the TC39 Signals proposal (Stage 1). It re-exports the full Signal namespace and adds a memory-safe watchSignal utility, isolating the rest of the codebase from potential Stage 1 API churn.
All signal imports in the XMachines ecosystem should come from this package, not directly from signal-polyfill, so that polyfill version bumps or API adaptations can be made 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)Computations automatically track every signal accessed inside them. Dynamic branching is fully supported — only signals read in the current execution path are tracked as dependencies.
watchSignal — memory-safe one-shot effect
Use watchSignal to subscribe to a Signal.State or Signal.Computed and receive its value after each change. Updates are coalesced into a single microtask per synchronous batch.
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 returned cleanup function is idempotent — calling it multiple times is safe and will not throw.
Signal.subtle.Watcher — low-level multi-signal observation
For advanced use cases such as framework integrations, the full Signal.subtle.Watcher API is available:
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 to control when dependents are notified:
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);// Setting structurally identical value will not notify 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 | Memory-safe subscription helper; 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 | Options bag for Signal.State constructor (equals?) |
ComputedOptions<T> | interface | Options bag for Signal.Computed constructor (equals?) |
WatcherNotify | type | Callback signature for Signal.subtle.Watcher notify function |
Testing
Run tests for this package in isolation:
# From this package directorynpm test
# Watch modenpm test -- --watchFrom the monorepo root:
# Run tests for this packagenpm test -w @xmachines/play-signals
# Run with coverage (lines ≥ 90 %, functions ≥ 90 %, branches ≥ 85 %, statements ≥ 90 %)npm run test:coverageRequirements
- Node.js
>= 22.0.0 - TypeScript
5.7+(for consumers using TypeScript)
License
MIT — see LICENSE.
TC39 Signals Polyfill for XMachines Play Architecture
Provides fine-grained reactive state primitives based on the TC39 Signals proposal (Stage 1). This package isolates the TC39 polyfill to protect the codebase from Stage 1 API changes.
Architectural Context: Implements Signal-Only Reactivity (INV-05) by providing the reactive primitives that enable Actor-to-Infrastructure communication without subscriptions or event emitters. All state propagation in Play Architecture uses TC39 Signals for automatic dependency tracking and glitch-free updates.
Example
Basic Signal usage
import { Signal } from "@xmachines/play-signals";
// Create state signalconst count = new Signal.State(0);
// Create computed signalconst doubled = new Signal.Computed(() => count.get() * 2);
// Observe 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 currently Stage 1 in the TC39 process. This package
uses the official signal-polyfill reference implementation to isolate the codebase
from potential API changes as the proposal evolves. All signal imports should go through
this package to maintain isolation.
Why Isolation: By re-exporting the polyfill through this dedicated package, we can update the polyfill version or adapt to API changes in one place without touching consuming packages. This architectural decision protects against Stage 1 API churn.