Skip to content

Understanding Atoms in XMachines

Atoms 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 atom 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 atoms 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.

Atoms solve this with a push–pull model:

  • The actor pushes state changes into atoms synchronously on every transition.
  • Consumers pull values from atoms, and a derivation computes only when a dependency changed.
  • 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 atom primitives

XMachines uses the atom of @xstate/store, accessed via @xmachines/play-atom. One function builds both kinds, and the argument decides which.

createAtom(value) — writable values

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

import { createAtom } from "@xmachines/play-atom";
const count = createAtom(0);
count.set(1); // write
count.set((previous) => previous + 1); // write from the previous value
count.get(); // read → 2

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

An atom notifies nobody when the new value compares equal to the old one. The default comparison is Object.is, and the compare option replaces it:

const point = createAtom(
{ x: 0, y: 0 },
{ compare: (previous, next) => previous.x === next.x && previous.y === next.y },
);

createAtom(fn) — derived values

ReadonlyAtom<T> derives a value from one or more other atoms. It recomputes only when its dependencies change, and it memoizes the result. The atom tracks each dependency for you: read another atom inside the function, and the edge exists.

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

In XMachines, actor.currentRoute is a ReadonlyAtom<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.

A computed atom with a subscriber is EAGER. @xstate/store builds a subscription on an effect that READS the atom, so a write to a dependency evaluates the derivation at once, inside that write. A computed atom that nobody subscribes to stays lazy, and it computes on a read. Keep a derivation cheap and free of a side effect.

Write one atom, derive the rest

Every set flushes by itself, and the library exposes no batching. Two writes therefore give two propagations, and an observer that reads both atoms is evaluated between them, on a half-updated pair.

The actor writes exactly one atom for each transition — state — and currentRoute and currentView are computed atoms over it. One write reaches both, the engine orders the evaluation, and no observer reads a torn pair. Follow the same rule in your own code: when two values must agree, derive the second from the first rather than writing both.

A derivation contains its own failures

A subscriber makes a computed atom eager, so the derivation runs inside the set of its dependency — before any observer sees anything. No subscriber can wrap that, so a derivation that something may watch must not throw:

const derived = createAtom((previous) => {
try {
return expensiveDerivation(source.get());
} catch (error) {
report(error);
return previous ?? null; // keep the last good value
}
});

actor.currentRoute and actor.currentView both follow this rule. Each reports through the onError option of the actor and keeps its last good value, so one unbuildable route template or one malformed meta.view never breaks the transition for the other observers.

createWritableAtom(value) — the writable atom, with one signature

createAtom carries two signatures, and TypeScript must prove the argument is not a function before it reaches the writable one. For a concrete type it proves that at once. For a type parameter, or a conditional type over one, it cannot, so it picks the computed signature and returns a ReadonlyAtom that carries no set.

class Holder<T extends { value: unknown }> {
public readonly state: Atom<T["value"]>;
constructor(seed: T["value"]) {
// createAtom(seed) would give a ReadonlyAtom here.
this.state = createWritableAtom(seed);
}
}

Use createAtom everywhere else.

atom.subscribe(fn) — reactive observation

Every atom carries subscribe. The observer runs synchronously, inside the set of the atom that changed, and it returns a subscription with unsubscribe().

const subscription = actor.currentRoute.subscribe((route) => {
console.log("Route is now:", route);
});
subscription.unsubscribe();

Two consequences follow from the synchronous delivery, and both matter:

  • Several writes in one tick call the observer several times. Nothing coalesces them.
  • An observer that THROWS throws inside the set, and the failure lands in the code that wrote the atom.

For both reasons, application code should use watchAtom() instead.


watchAtom() — the safe helper

@xmachines/play-atom exports watchAtom(atom, onValue) as a lifecycle-safe wrapper around subscribe. It returns a cleanup function:

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

watchAtom() handles three subtle correctness concerns for you:

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

Use watchAtom() in framework adapters and application code. Use the raw subscribe only when you need the notification at the exact moment of the write.


The release, and using

watchAtom() returns a Cleanup from @xmachines/play: a function, and a Disposable. One value therefore serves two forms, and the same holds for the mount of @xmachines/play-dom and for history.subscribe() of @xmachines/play-dom-router.

Keep the explicit call where the release outlives the scope that made it — a field that a teardown reads, an entry in a Set of releases:

const stop = watchAtom(actor.currentRoute, render);
onUnmount(() => stop());

Write using where the release belongs to one scope. The scope releases it, an exception releases it too, and the code carries no teardown:

{
using stop = watchAtom(actor.currentRoute, render);
render(actor.currentRoute.get());
// stop() runs here, and it runs even when render() throws
}

A scope releases from the last declaration to the first, so a release that depends on an earlier one is safe.

Every release here is synchronous, so Cleanup is a Disposable and not an AsyncDisposable. That is the permissive choice: a Disposable serves using AND await using, while an AsyncDisposable raises a TypeError under a plain using and forces every holder into an async function. asCleanup therefore refuses a release that returns a value, and a promise above all — a promise handed to it would go on the floor, released and unawaitable.

using needs Node 24 or a browser with Explicit Resource Management. The publishable packages build at target: "esnext" and pass the syntax through untouched, so your bundler decides how to emit it. The Cleanup VALUE needs neither: a runtime without Symbol.dispose still calls it as a plain function.


One instance, and why it matters

@xstate/store holds the active subscriber in a variable of its own module. Two copies in one application build two separate graphs: a computed atom of the first copy and a subscription of the second copy never meet, the subscriber stays silent, and no error reports the fault. Every propagation of state in this architecture goes through an atom, so the second copy stops the whole reactivity of an application.

@xmachines/play-atom therefore declares @xstate/store as a peer dependency. A renderer declares it no more, because it reads the atom through @xmachines/play-atom and names the engine in no import. Install one copy, and install it yourself. tests/singleton-peers.test.ts holds the rule true inside this workspace.


Why not observables (RxJS) or event emitters?

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

1. One primitive, isolated. Isolating the library behind @xmachines/play-atom means one package holds every change of the reactive primitive, and no consuming package changes with it.

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. Atoms propagate atomically: every computed atom updates before any subscriber runs, and the algorithm resolves the diamond so that no subscriber reads a half-updated graph.

3. Memoized derivation. An observable pushes a value to every subscriber whatever the value is. An atom compares, and a derivation that produces an equal value notifies nobody and stops the propagation there.


The invariants in XMachines

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

InvariantWhat it means in practice
1. Actor AuthorityOnly actor code writes to actor.state. Never write to it from infrastructure.
2. Strict SeparationBusiness logic (machine definition) never imports browser APIs, routing libraries, or framework packages.
3. Atom-Only ReactivityCross-boundary communication uses atoms. Infrastructure does not poll getSnapshot().
4. Passive InfrastructureRouters and renderers read atoms and forward events. They never decide state transitions.
5. State-Driven ResetInvalid external navigation is overwritten by actor-derived state.

Cleanup is mandatory

Atoms do not clean themselves up when they go out of scope. Every subscription 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 the cleanup returned by watchAtom().
  • Router bridge disconnect() methods must release all atom subscriptions.
  • If you use subscribe directly, pair every call with unsubscribe() in teardown.

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


Summary

PrimitiveRole in XMachinesWho uses it
createAtom(value)Actor output: writable snapshot and view stateActor writes; infrastructure reads
createAtom(fn)Derivations: routes, view specsActor defines; adapters read
createWritableAtom(value)The writable atom, for a generic value typeCapability and mixin code
atom.subscribe(fn)Synchronous observation, at the writeInfrastructure that needs no delay
watchAtom()Lifecycle-safe, coalesced subscriptionApplication code, adapter code

See also