Skip to content

Migrating to @xmachines/play-atom

@xmachines/play-signals is gone. @xmachines/play-atom replaces it.

The old package wrapped the TC39 Signals polyfill. The new package re-exports the atom of @xstate/store, whose reactive core adapts the Alien Signals algorithm.

The two APIs agree on the shape: both read with get(), and both write with set(). A codemod therefore does most of the work. Three behaviours differ, and no codemod can decide those for you — read What the codemod cannot do.


Why

Every renderer of this workspace already imported createAtom from @xstate/store, for the state store of the view. Every application therefore shipped TWO reactive graphs: the Signals polyfill for the actor, the route and the view, and the atom for the view store. The move collapses them into one.

It also drops about 3 KB gzip, and it inherits the diamond fix and the compare option that merge request statelyai/xstate#5250 landed.


Run the codemod

The script travels with the repository, and no package publishes it. Download it first:

Terminal window
curl -O https://gitlab.com/xmachin-es/xmachines-js/-/raw/main/scripts/codemod-play-atom.mjs
Terminal window
# Look first. It writes nothing.
node codemod-play-atom.mjs --dry-run .
# Then write.
node codemod-play-atom.mjs .

The codemod reads .ts, .tsx, .mts, .cts, .js, .jsx, .mjs, .vue, .svelte, .md and .json, so it covers a manifest and a single-file component too. It skips node_modules, dist, coverage and every directory of generated documentation.

It exits non-zero when it finds a site that needs a human, and it names each one.

Install the new package afterwards:

Terminal window
pnpm remove @xmachines/play-signals
pnpm add @xmachines/play-atom @xstate/store

The name table

@xmachines/play-signals@xmachines/play-atom
new Signal.State(value)createAtom(value)
new Signal.Computed(fn)createAtom(fn)
Signal.State<T> (type)Atom<T>
Signal.Computed<T> (type)ReadonlyAtom<T>
SignalState<T> / SignalComputed<T>Atom<T> / ReadonlyAtom<T>
{ equals }{ compare }
SignalOptions<T> / ComputedOptions<T>AtomOptions<T>
watchSignal(signal, onValue)watchAtom(atom, onValue)
Cleanup (type)() => void — the codemod reports it, and rewrites it not
useSignalEffect(cb, deps) (play-react)useAtomEffect(sources, cb, deps)
Signal.subtle.Watcheratom.subscribe(fn), or watchAtom
Signal.subtle.untrack(fn)— no equivalent. See below
signal.set(value)atom.set(value), and atom.set(fn)

createAtom also accepts a function in set, which Signal.State did not.


What the codemod cannot do

1. A computed atom with a subscriber is EAGER

A TC39 Signal.Computed stayed lazy. A watcher notified, and the consumer read the value when it chose to.

@xstate/store builds a subscription on an effect that READS the atom. A write to a dependency therefore evaluates the derivation at once, inside that write.

What this changes:

  • A derivation runs one time for each write of a dependency, and not one time for each microtask. Keep a derivation cheap, and keep it free of a side effect that must not repeat.
  • useAtomEffect of @xmachines/play-react runs your callback from watchAtom, which coalesces. Three writes in one tick therefore run the callback ONE time, with the last value, exactly as useSignalEffect did. The eagerness above applies to the DERIVATION, and not to your callback.

A computed atom that nobody subscribes to still stays lazy.

Write ONE atom for each change, and derive the rest. The library exposes no batching, so every set flushes by itself: two writes give two propagations, and an observer that reads both atoms is evaluated in between, on a half-updated pair. The actor of this workspace writes state alone for that reason, and currentRoute and currentView derive from it. If your own code writes two atoms that must agree, make one of them a createAtom(fn) over the other.

2. The propagation is SYNCHRONOUS, inside set

An observer of subscribe runs inside the set of the atom that changed. An observer that THROWS therefore throws inside that set, and the failure lands in the code that wrote the atom — a failure of one view would break the actor for every other observer of it.

Contain it. watchAtom does this for you, because it defers the callback to a microtask. useAtomEffect catches a throw of a later run and throws it again from a microtask, which reaches the global handler of the runtime.

If you call subscribe directly, wrap the body.

A DERIVATION must contain its own failures. A subscriber makes a computed atom eager, so the derivation itself runs inside the set, before any observer. No subscriber can wrap that, so the rule belongs to the derivation: report through your error channel, and return the previous value. currentRoute and currentView of the actor both do this, and a release before this one let currentRoute throw — which was safe only while the TC39 watcher read it from a microtask.

import { createAtom, type ReadonlyAtom } from "@xmachines/play-atom";
declare const source: ReadonlyAtom<string>;
declare function expensiveDerivation(input: string): number;
declare function report(error: unknown): void;
// A derivation that a subscriber may watch.
const derived = createAtom<number | null>((previous) => {
try {
return expensiveDerivation(source.get());
} catch (error) {
report(error);
return previous ?? null;
}
});

3. There is no untrack

@xstate/store exports no equivalent of Signal.subtle.untrack, because it keeps the active subscriber in a variable of its own module.

Derive the value, and read previous. Almost every untrack guarded the same shape: a gate that compares the new value with the last one it emitted. A computed atom is handed that last value, so the gate needs no read and therefore tracks nothing. withView of @xmachines/play-xstate is the worked example:

// Before: a written atom, plus untrack to read what it held.
this.currentView = new Signal.State<View | null>(null);
const last = Signal.subtle.untrack(() => this.currentView.get());
if (!equivalent(last, next)) this.currentView.set(next);
// After: a computed atom. `previous` IS what the atom holds.
this.currentView = createAtom((previous): View | null => {
const last = previous ?? null;
const next = derive(this.state.get());
return equivalent(last, next) ? last : next;
});

The second form is shorter, and it removes more than the untrack: the atom stops being written, so the gate keeps no bookkeeping of its own.

A read that already stands outside every computed atom and every effect tracks nothing, so it needs no wrapper at all.

4. useAtomEffect takes the sources first

useSignalEffect(callback, deps) tracked every signal that its callback read. useAtomEffect(sources, callback, deps) tracks the atoms of its FIRST argument, and the callback runs OUTSIDE the tracking scope.

The codemod renames the call, and it cannot write the source list. Add that array yourself, and move the callback one place to the right:

// Before
useSignalEffect(() => {
setView(actor.currentView.get());
}, [actor]);
// After
useAtomEffect(
[actor.currentView],
() => {
setView(actor.currentView.get());
},
[actor],
);

The list is the reason the hook exists in this form: a callback that reads a view store no longer depends on that store, so a write of the store runs the effect never. Name every atom that must run the effect, and read whatever else you need inside the callback.


Install ONE copy of @xstate/store

@xstate/store holds the active subscriber in a variable of its own module. Two copies 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.

@xmachines/play-atom declares @xstate/store as a PEER dependency for this reason. A renderer declares it no more: a renderer reads the atom through @xmachines/play-atom, and it imports the engine in no source file. Declare it once in your application, and let every @xmachines package resolve to that copy.

Check an install:

Terminal window
pnpm why @xstate/store

More than one version in the output is the fault above.

The engine moved to @xstate/store 4

XMachines 5.0.0 declares @xstate/store ^4.2.3, where the release before it declared ^3.17.0. Install the new major with this release.

One signature changed. A computed getter took a read helper in its first position in version 3, and it takes the previous value alone now, because get records the dependency by itself. The codemod writes the new form, and a derivation that you wrote by hand needs the first parameter deleted:

const derived = createAtom((_read, previous) => …);
const derived = createAtom((previous) => …);

createAsyncAtom gives its getter an AbortSignal now. A getter that needs none takes no parameter, so nothing forces a change.


A type error that names a type you never wrote

Property 'set' is missing in type 'ReadonlyAtom<T>' but required in type 'Atom<T>'

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.

Use createWritableAtom, which states the one signature:

import { createWritableAtom } from "@xmachines/play-atom";
this.state = createWritableAtom(this.getSnapshot());

See also