Skip to content

Understanding TC39 Signals in XMachines

TC39 Signals are the reactive substrate connecting actors to the outside world in XMachines. This page explains what they are, why XMachines uses them instead of observables or callbacks, and how the three Signal primitives map to distinct roles in the architecture.

After reading this, you will understand why infrastructure never reads state directly from an actor — and what it does instead.


What problem Signals solve

When a state machine transitions, several things may need to react: the URL bar might update, a router might navigate, a renderer might swap views. The naive approach is to let each of those consumers call actor.getSnapshot() whenever they feel like it — or to have the actor call back into each consumer via callbacks.

Both approaches break down:

  • Polling (getSnapshot() on a timer or event) is racy: consumers can observe inconsistent intermediate states during a transition sequence.
  • Callbacks create direct dependencies between the actor and infrastructure, violating the architectural invariant that business logic must not depend on runtime APIs.

TC39 Signals solve this with a push–pull model:

  • The actor pushes state changes into signals synchronously on every transition.
  • Consumers pull computed values lazily from signals — only when they actually need the value.
  • Changes propagate atomically, so no consumer ever sees a half-updated state.

This model is sometimes called glitch-free reactivity: intermediate invalid states never escape to the environment.


The three Signal primitives

XMachines uses three primitives from the TC39 Signals proposal, accessed via @xmachines/play-signals:

Signal.State — writable values

Signal.State<T> holds a single mutable value. Only the actor (or code it explicitly delegates to) writes to it. Everything else reads from it.

import { Signal } from "@xmachines/play-signals";
const count = new Signal.State(0);
count.set(1); // write
count.get(); // read → 1

In XMachines, actor.state is a Signal.State<Snapshot>. The actor updates it on every XState transition. Infrastructure reads it — never writes it.

Signal.Computed — derived values

Signal.Computed<T> derives a value from one or more other signals. It recomputes only when its dependencies change — and only when something reads it (lazy evaluation).

const route = new Signal.Computed(() => {
const snapshot = actor.state.get();
return snapshot.getMeta()?.route ?? null;
});

In XMachines, actor.currentRoute is a Signal.Computed<string | null>. Router adapters read it to know which URL to push — they do not compute the route themselves. Route derivation stays in the actor where the business logic lives.

Signal.subtle.Watcher — reactive observation

Signal.subtle.Watcher is the low-level primitive for reacting to signal changes. When a watched signal’s value changes, the watcher’s notify callback fires synchronously. You then schedule the actual work with queueMicrotask to avoid re-entrant signal reads.

const watcher = new Signal.subtle.Watcher(() => {
queueMicrotask(() => {
const pending = watcher.getPending();
for (const signal of pending) {
signal.get(); // re-read to flush
}
watcher.watch(...pending); // re-arm for next change
});
});
watcher.watch(actor.currentRoute);
actor.currentRoute.get(); // initial read required to arm the watcher

Watcher notifications are one-shot: if you do not call watch() again after draining pending signals, you will miss subsequent changes.

Router bridges and renderers use Signal.subtle.Watcher internally. For most application code, the watchSignal() helper covers the common case.


watchSignal() — the safe helper

@xmachines/play-signals exports watchSignal(signal, onValue) as a lifecycle-safe wrapper around the raw watcher pattern. It returns a cleanup function:

import { watchSignal } from "@xmachines/play-signals";
const stop = watchSignal(actor.currentRoute, (route) => {
console.log("Route is now:", route);
});
// Later — stops observing and prevents use-after-free
stop();

watchSignal() handles three subtle correctness concerns for you:

ConcernWhat happens without itHow watchSignal handles it
Use-after-freeCallback fires after component unmountsdisposed flag checked before invoking callback
CoalescingRapid synchronous changes cause multiple callbacksneedsEnqueue guard — only one microtask queued per synchronous burst
Idempotent cleanupCalling the cleanup twice throwsSafe to call multiple times

Use watchSignal() in framework adapters and application code. Use the raw Signal.subtle.Watcher only when building infrastructure that needs to watch multiple signals independently.


Why not observables (RxJS) or event emitters?

The XMachines architecture chose TC39 Signals over observable libraries and event emitters for three reasons:

1. Standardization. Signals are a Stage 1 TC39 proposal. They are not tied to any library, bundler, or framework. Isolating the polyfill behind @xmachines/play-signals means the entire ecosystem can migrate to native signals when the proposal lands, with a single package update.

2. Synchronous atomic propagation. RxJS streams are asynchronous by default. Event emitters fire immediately but serially — a listener registered halfway through a sequence can see inconsistent state. Signals propagate atomically: all computeds update before any watcher fires.

3. Pull-based evaluation. Observables push values to every subscriber immediately. Signals are lazy: Signal.Computed does not recompute until something reads it. An adapter that is not currently mounted does not pay the cost of computing derived values.


The five Signal invariants in XMachines

The Play RFC defines five invariants that govern how signals are used:

InvariantWhat it means in practice
INV-01: Actor AuthorityOnly actor code writes to actor.state. Never write to it from infrastructure.
INV-04: Passive InfrastructureRouters and renderers read signals and forward events. They never decide state transitions.
INV-05: Signal-Only ReactivityCross-boundary communication uses signals. Infrastructure does not poll getSnapshot().
INV-03: No Direct QueriesInfrastructure reads from signals, not from snapshot methods, outside of actor code.
INV-02: Strict SeparationBusiness logic (machine definition) never imports browser APIs, routing libraries, or framework packages.

Cleanup is mandatory

Signals do not clean themselves up when they go out of scope. Every watcher you create must be explicitly disposed when the consuming component or adapter unmounts.

  • Framework lifecycle hooks (useEffect cleanup in React, onUnmounted in Vue, onCleanup in Solid) must call unwatch() or the cleanup returned by watchSignal().
  • Router bridge disconnect() methods must unwatch all signal subscriptions.
  • If you use the raw Signal.subtle.Watcher API, pair every watch() call with unwatch() in teardown.

Failing to clean up watchers causes memory leaks and stale callbacks firing after the consumer is gone.


Summary

PrimitiveRole in XMachinesWho uses it
Signal.StateActor output: writable snapshot and view stateActor writes; infrastructure reads
Signal.ComputedLazy derivations: routes, view specsActor defines; adapters read
Signal.subtle.WatcherLow-level reactive observationFramework adapters, router bridges
watchSignal()Lifecycle-safe single-signal subscriptionApplication code, adapter code

See also