Stately
XState v6 alpha

Atoms and React

Expose an Effect-backed actor to a reactive UI.

XState v6 is in alpha

APIs and behavior may change before the stable release.

@xstate/effect/atom exposes an actor through effect/unstable/reactivity, so a reactive UI reads it the way it reads any other Effect state.

effect/unstable/reactivity is an unstable Effect module. This entry point follows it and may change independently of the rest of the package.

createActorAtoms

createActorAtoms(runtime, logic, options?) takes an Atom.runtime whose Layer provides the logic's services, and returns:

AtomType
actorAtom<AsyncResult<EffectActor<TLogic>>>
snapshotAtom<AsyncResult<Snapshot>>
resultAtom<AsyncResult<Snapshot, ErrorFrom<TLogic>>>, a Failure once the actor errors
sendWritable<AsyncResult<void, NotReadyError>, Event>, set it with an event
select(f)Atom<AsyncResult<T>> derived from snapshot
stateAtom<AsyncResult<TaggedState>>, the snapshot as a tagged union
import { Effect, Layer } from 'effect';
import { Atom, AtomRegistry, AsyncResult } from 'effect/unstable/reactivity';
import { createActorAtoms } from '@xstate/effect/atom';

const runtime = Atom.runtime(
  Layer.succeed(Api, { fetchUser: (id: string) => Effect.succeed({ id }) })
);
const user = createActorAtoms(runtime, machine);
const status = user.select((snapshot) => snapshot.value);

const registry = AtomRegistry.make();
registry.subscribe(
  status,
  (result) => {
    if (AsyncResult.isSuccess(result)) {
      console.log(result.value);
    }
  },
  { immediate: true }
);
registry.set(user.send, { type: 'RETRY' });

The actor starts when one of its atoms is first read and stops when nothing reads or mounts them anymore. Values are AsyncResult because the runtime's Layer builds asynchronously.

A runtime that does not provide a service the logic requires is a type error on the runtime argument.

send and NotReadyError

The send atom enqueues the event, like actor.send. Its value reports the last send. Setting it before the runtime has finished building records a NotReadyError failure instead of sending, and NotReadyError is exported from @xstate/effect/atom.

result

result is snapshot with an errored actor reported as a Failure carrying the actor's error, which is what an error boundary needs. snapshot keeps reporting the error snapshot as a success.

Keeping the actor alive

Wrap an atom with Atom.keepAlive to keep the actor for the registry's lifetime. Build the atoms inside Atom.family to get one actor per input.

const userAtoms = Atom.family((id: string) =>
  createActorAtoms(runtime, userMachine, { input: { id } }).snapshot
);

React

useMachine, useActor and useActorRef from @xstate/react call createActor internally, so they cannot start Effect-backed logic. In an Effect application the actor lives in the runtime and React reads it through atoms, with the hooks from @effect/atom-react.

import { Suspense } from 'react';
import { Atom } from 'effect/unstable/reactivity';
import { useAtomSet, useAtomSuspense } from '@effect/atom-react';
import { createActorAtoms } from '@xstate/effect/atom';

const runtime = Atom.runtime(AppLayer);
const checkout = createActorAtoms(runtime, checkoutMachine);
const status = checkout.select((snapshot) => snapshot.value);

function Checkout() {
  const { value } = useAtomSuspense(status);
  const send = useAtomSet(checkout.send);

  return <button onClick={() => send({ type: 'PAY' })}>{String(value)}</button>;
}

export function App() {
  return (
    <Suspense fallback={null}>
      <Checkout />
    </Suspense>
  );
}

useAtomSuspense suspends until the runtime and the actor are ready. useAtomValue returns the AsyncResult instead, for components that render their own loading state.

The actor starts when the first component reads one of its atoms and stops when the last one unmounts. An owner component can hold it with useAtomMount(checkout.actor) while children read selectors. Pin it with Atom.keepAlive when it must outlive the components.

Without atoms

The EffectActor handle is an XState actor reference, so useSelector from @xstate/react reads it directly. Create the actor through a ManagedRuntime, pass the handle down, and call actor.send as usual.

import { useSelector } from '@xstate/react';

function Total({ actor }: { actor: EffectActor<typeof checkoutMachine> }) {
  const total = useSelector(actor, (snapshot) => snapshot.context.total);

  return <button onClick={() => actor.send({ type: 'PAY' })}>{total}</button>;
}

On this page