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); // writecount.get(); // read → 1In 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 watcherWatcher 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-freestop();watchSignal() handles three subtle correctness concerns for you:
| Concern | What happens without it | How watchSignal handles it |
|---|---|---|
| Use-after-free | Callback fires after component unmounts | disposed flag checked before invoking callback |
| Coalescing | Rapid synchronous changes cause multiple callbacks | needsEnqueue guard — only one microtask queued per synchronous burst |
| Idempotent cleanup | Calling the cleanup twice throws | Safe 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:
| Invariant | What it means in practice |
|---|---|
| INV-01: Actor Authority | Only actor code writes to actor.state. Never write to it from infrastructure. |
| INV-04: Passive Infrastructure | Routers and renderers read signals and forward events. They never decide state transitions. |
| INV-05: Signal-Only Reactivity | Cross-boundary communication uses signals. Infrastructure does not poll getSnapshot(). |
| INV-03: No Direct Queries | Infrastructure reads from signals, not from snapshot methods, outside of actor code. |
| INV-02: Strict Separation | Business 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 (
useEffectcleanup in React,onUnmountedin Vue,onCleanupin Solid) must callunwatch()or the cleanup returned bywatchSignal(). - Router bridge
disconnect()methods must unwatch all signal subscriptions. - If you use the raw
Signal.subtle.WatcherAPI, pair everywatch()call withunwatch()in teardown.
Failing to clean up watchers causes memory leaks and stale callbacks firing after the consumer is gone.
Summary
| Primitive | Role in XMachines | Who uses it |
|---|---|---|
Signal.State | Actor output: writable snapshot and view state | Actor writes; infrastructure reads |
Signal.Computed | Lazy derivations: routes, view specs | Actor defines; adapters read |
Signal.subtle.Watcher | Low-level reactive observation | Framework adapters, router bridges |
watchSignal() | Lifecycle-safe single-signal subscription | Application code, adapter code |
See also
- Understanding the Actor Model — why the actor owns all signal writes
- Understanding State Machines — how state nodes produce the metadata that signals derive
- Getting Started — hands-on walkthrough using signals to observe an actor
- @xmachines/play-signals — API reference
- Play RFC — architectural specification
- TC39 Signals proposal — upstream proposal