Stately
XstateV6XState Store

Source atoms

Share external snapshot subscriptions across atom consumers.

XState v6 is in beta

APIs and behavior may change before the stable release.

Use createSourceAtom to expose an external value, such as a media query or a browser observer, as a read-only atom. Provide getSnapshot to read its current value and subscribe to attach a change listener. The listener calls notify(); XState Store reads the latest snapshot. Return either a cleanup function or a subscription object with an unsubscribe() method. XState Store normalizes the adapter result internally; the atom's public .subscribe() always returns a subscription object, so consumers call subscription.unsubscribe().

import { createAtom, createSourceAtom } from '@xstate/store';

const media = window.matchMedia('(prefers-color-scheme: dark)');
const isDark = createSourceAtom({
  getSnapshot: () => media.matches,
  subscribe: (notify) => {
    media.addEventListener('change', notify);
    return () => media.removeEventListener('change', notify);
  }
});
const theme = createAtom(() => (isDark.get() ? 'dark' : 'light'));

// Starts one media query listener, through the derived atom.
const derived = theme.subscribe((value) => console.log(value));
const direct = isDark.subscribe((value) => console.log(value));

// The direct subscriber still needs the listener.
derived.unsubscribe();
// Removes the listener synchronously.
direct.unsubscribe();

Create browser sources in an environment where their browser APIs are available. For server rendering, provide a snapshot reader that works on the server; a plain read does not attach the external listener.

Reading and subscribing

Creation is lazy. Calling the source atom's .get() without a live consumer reads getSnapshot without calling subscribe. Reading an unobserved derived atom also does not start external subscriptions. Derived atoms cache their values; an unobserved derived read does not poll external sources for changes.

The first direct subscription, or subscription to a derived atom that reads the source, starts the listener. Consumers share one listener per source atom instance. Setup occurs after dependency evaluation settles and before the outer subscription or update call returns. XState Store attaches the listener before refreshing the snapshot, including when registration calls notify() synchronously.

Like other atoms, subscribing does not emit the initial value. Call .get() to read it. A snapshot change during listener registration can notify subscribers before subscribe() returns.

When a conditional derived atom stops reading the source, its consumer is removed. The listener remains active while any other direct or indirect consumer needs it. Cleanup runs after graph updates settle, before the last unsubscribe() or update call returns. Moving a dependency between derived branches during the same update does not restart a shared listener. Separate unsubscribe/resubscribe calls do.

Subscribing again reconnects the listener and refreshes the snapshot. Notifications from an earlier, cleaned-up listener are ignored, including after reconnection.

Snapshot contract

getSnapshot should return a stable value until the external source changes, and subscribe should notify whenever that value changes. Reads inside these callbacks do not establish atom dependencies. Model reactive dependencies in a derived atom instead.

Snapshots use Object.is comparison by default. Provide a custom comparator when an external source returns a new object for an unchanged value:

const size = createSourceAtom(
  {
    getSnapshot: () => ({ width: window.innerWidth, height: window.innerHeight }),
    subscribe: (notify) => {
      window.addEventListener('resize', notify);
      return { unsubscribe: () => window.removeEventListener('resize', notify) };
    }
  },
  {
    compare: (previous, next) =>
      previous.width === next.width && previous.height === next.height
  }
);

Source atoms expose .get() and .subscribe(), with no .set() or .send(). They do not provide timed retries or an error state. A failed activation with live consumers remains eligible for another attempt on a later atom operation. It is never retried within the failing operation. Synchronous snapshot, registration, notification, and cleanup errors propagate to the caller. If registration fails before returning a subscription, the adapter must release any resources it acquired.

Use XState actors when a resource needs commands, retries, reconnection backoff, or a richer lifecycle. Adapt an actor snapshot with getSnapshot: () => actor.getSnapshot() and subscribe: (notify) => actor.subscribe(notify); the actor's owner remains responsible for starting and stopping it.

On this page