@xmachines/play-atom
API / @xmachines/play-atom
The atom primitives for XMachines. They give the Play Architecture fine-grained reactive state. The primitives propagate state without a glitch and without a subscription of the consumer.
Installation
pnpm add @xmachines/play-atom @xstate/storePeer dependency. Install it with the package:
@xstate/store^4.2.3— the atom engine.
Install ONE copy of @xstate/store — see One instance.
Overview
This package DECLARES the reactive contract of the Play Architecture, and it binds an engine to that contract in one module. The engine of today is @xstate/store, whose reactive core adapts the Alien Signals algorithm. It adds the watchAtom utility, which owns the subscription lifecycle, and createWritableAtom, which removes an overload hazard for a generic value type.
The types belong to THIS package. Atom, Readable and each name beside them are declarations here, and no forward of a type of the engine. Therefore the published .d.ts of a consumer names the engine never, and a change of the engine reaches this package alone.
Import every atom in the XMachines ecosystem from this package, and not from @xstate/store. One import point keeps each update and each API change in one place.
Usage
createAtom(value) — writable reactive state
import { createAtom } from "@xmachines/play-atom";
const count = createAtom(0);
console.log(count.get()); // 0count.set(5);count.set((previous) => previous + 1);console.log(count.get()); // 6createAtom(fn) — memoized derived values
import { createAtom } from "@xmachines/play-atom";
const count = createAtom(0);const doubled = createAtom(() => 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)A computation tracks each atom that it reads. A dynamic branch is safe: the computation keeps only the atoms of the current execution path as its dependencies.
The getter takes ONE parameter: the value that the atom holds already. It is undefined before the first derivation. Return it to keep the atom still, which stops the propagation at this atom:
import { createAtom } from "@xmachines/play-atom";
const source = createAtom({ title: "Home", clicks: 0 });
const view = createAtom<{ title: string }>((previous) => { const next = { title: source.get().title }; // A click changes no title, so the atom keeps the object that it holds return previous?.title === next.title ? previous : next;});A computed atom that something SUBSCRIBES to is eager.
@xstate/storebuilds 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 with no subscriber stays lazy.
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.
import { createWritableAtom, type Atom } from "@xmachines/play-atom";
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.
watchAtom — the coalesced subscription
Use watchAtom to subscribe to an Atom or to a ReadonlyAtom. The callback receives the value after each change. watchAtom groups the updates of one synchronous batch into a single microtask.
import { createAtom, watchAtom } from "@xmachines/play-atom";
const count = createAtom(0);
const cleanup = watchAtom(count, (value) => { console.log("count changed:", value);});
count.set(1); // coalesced with any rapid synchronous changescount.set(2);count.set(3); // → logs "count changed: 3" once, from a microtask
// Stop watchingcleanup();The cleanup function is idempotent. A second call is safe, and it does not throw. It is a plain function. To release it at the end of a scope, publish it with asDisposable of @xmachines/core/utils: using cleanup = asDisposable(watchAtom(...)).
atom.subscribe — synchronous observation
Every atom carries subscribe. The observer runs SYNCHRONOUSLY, inside the set of the atom that changed, and nothing coalesces the calls. An observer that throws therefore throws inside that set, and the failure lands in the code that wrote the atom. Prefer watchAtom unless you need the notification at the exact moment of the write.
import { createAtom } from "@xmachines/play-atom";
const count = createAtom(0);
const subscription = count.subscribe((value) => console.log(value));subscription.unsubscribe();The subscription is the plain { unsubscribe } of the engine. To hold it with using, publish it with asDisposable of @xmachines/core/utils:
import { asDisposable } from "@xmachines/core/utils";import { createAtom } from "@xmachines/play-atom";
const count = createAtom(0);
{ const plain = count.subscribe((value) => console.log(value)); using subscription = asDisposable(plain, () => plain.unsubscribe()); count.set(1);} // the scope calls unsubscribe()Custom equality
createAtom accepts a compare option. It controls when an atom notifies its dependents. The default is Object.is.
import { createAtom } from "@xmachines/play-atom";import type { AtomOptions } from "@xmachines/play-atom";
const options: AtomOptions<{ name: string; age: number }> = { compare: (a, b) => a.name === b.name && a.age === b.age,};
const person = createAtom({ name: "Alice", age: 30 }, options);// A structurally identical value does not notify the dependentsperson.set({ name: "Alice", age: 30 });One instance
@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.
This package therefore declares @xstate/store as a peer dependency. A renderer does not declare it: a renderer imports the engine in no source file, and it reads the atom through this package. Bring one copy, and bring it yourself.
API Summary
| Export | Kind | Description |
|---|---|---|
createAtom(value, options?) | function | The writable atom |
createAtom(fn, options?) | function | The computed atom |
createWritableAtom(value, opts?) | function | The writable atom, with one signature, for a generic value type |
createAsyncAtom(fn, options?) | function | The computed atom of a promise. It holds an AsyncAtomState. fn reads an AbortSignal |
watchAtom(atom, onValue) | function | The coalesced subscription helper. It returns a plain release |
Atom<T> | interface | The writable atom (.get(), .set(), .subscribe()) |
ReadonlyAtom<T> | interface | The computed atom (.get(), .subscribe()) |
Readable<T> | interface | The read side of either one (.get(), .subscribe()) |
BaseAtom<T> | interface | The common base of both kinds |
AnyAtom | type | An atom of an unknown value type |
AtomOptions<T> | interface | The options object of createAtom (compare?) |
AtomObserver<T> | interface | The observer of subscribe, in the object form |
AtomSubscription | interface | The release that subscribe returns |
AsyncAtomState<TData> | type | The state of createAsyncAtom: pending, done, or error |
AsyncAtomOptions | interface | What createAsyncAtom hands its getter: the signal that aborts a stale run |
CreateAtom | interface | The two call signatures of createAtom |
The observer types carry the Atom prefix on purpose: xstate declares Observer and Subscription too, so a plain name here would put two declarations of one name in one file. Subscribable stays out of the surface for the same reason.
Migrating from @xmachines/play-signals
This package replaces @xmachines/play-signals, which wrapped the TC39 Signals polyfill. A codemod rewrites the call sites:
curl -O https://gitlab.com/xmachin-es/xmachines-js/-/raw/main/scripts/codemod-play-atom.mjsnode codemod-play-atom.mjs --dry-run .See Migrating to @xmachines/play-atom for the name table and the three behaviour changes that no codemod can make for you.
Requirements
- Node.js
>= 24.0.0 - TypeScript
5.7+(for a consumer that uses TypeScript) @xstate/store^4.2.3— the atom engine, and a PEER dependency
XMachines 5.0.0 moved the engine from @xstate/store 3 to 4. A computed getter took a read helper in its first position there, and it takes the previous value alone now. Delete that first parameter:
const derived = createAtom((_read, previous) => …);const derived = createAtom((previous) => …);License
MIT — see LICENSE.
The atom primitives of the XMachines Play Architecture
This package gives you the fine-grained reactive state primitives that carry every value across a boundary of the Play Architecture. It keeps the reactive primitive in one place, and it therefore protects the code from a change of the underlying library.
Architectural context: the package implements Atom-Only Reactivity (INV-03). It gives the reactive primitives that carry the communication from the Actor to the infrastructure, without a subscription of the consumer and without an event emitter. Every propagation of state in the Play Architecture uses an atom, which tracks each dependency and updates without a glitch.
Example
The basic use of an atom
import { createAtom, watchAtom } from "@xmachines/play-atom";
// Create a writable atomconst count = createAtom(0);
// Create a computed atomconst doubled = createAtom(() => count.get() * 2);
// Observe the changesconst stop = watchAtom(doubled, (value) => { console.log("Count:", count.get(), "Doubled:", value);});
count.set(5); // Logs: Count: 5 Doubled: 10See
- Play RFC - invariant INV-03
- XState Store
Remarks
The engine: the atom comes from @xstate/store, whose reactive core adapts the
Alien Signals algorithm. The algorithm resolves the diamond problem, it recomputes no
value that no dependency changed, and it accepts an equality function of your own
through the compare option.
The reason for the separation: this dedicated package DECLARES the contract, and it binds the engine to that contract in one module. Therefore one place holds each new version, each change of the API, and the choice of the engine itself. A consumer names the engine in no import and in no manifest, so a change of the engine reaches this package alone.
@xstate/store is a PEER dependency, and it must resolve to ONE instance. The
library holds the active subscriber in a module variable. Two copies in one
application track no dependency across a package boundary, and a computed atom then
updates never.
Interfaces
- AsyncAtomOptions
- Atom
- AtomObserver
- AtomOptions
- AtomSubscription
- BaseAtom
- CreateAtom
- Readable
- ReadonlyAtom