Skip to content

@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

Terminal window
npm install @xmachines/play-signals

Overview

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

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 changes
count.set(3); // → logs "count changed: 3" once
// Stop watching
cleanup();

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 notification

Custom 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 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)functionMemory-safe subscription helper; 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>interfaceOptions bag for Signal.State constructor (equals?)
ComputedOptions<T>interfaceOptions bag for Signal.Computed constructor (equals?)
WatcherNotifytypeCallback signature for Signal.subtle.Watcher notify function

Testing

Run tests for this package in isolation:

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

From the monorepo root:

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

Requirements

  • 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 signal
const count = new Signal.State(0);
// Create computed signal
const doubled = new Signal.Computed(() => count.get() * 2);
// Observe 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 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.

Namespaces

Interfaces

Type Aliases

Functions