Integration between effect and xstate:
- Create
effectatoms fromxstateactors - Create
xstateactors fromeffect(atoms, effects, streams)
effect atom owns the component-facing reactive graph and actor lifecycle, xstate owns machines, actors, invocations, emitted events, delays.
Note: I may or may not support this long-ish term.
Effect may implement State Machines internally soon. XState is approaching v6, which may bring completely new integration requirements.
If you are interested in this project, you can use it from
@typeonce/effect-xstate, or consider copying the code for the APIs you need directly in your codebase.
This requires xstate v5 and effect v4 beta.
pnpm add @typeonce/effect-xstatenpm install @typeonce/effect-xstateyarn add @typeonce/effect-xstateeffect and xstate are peer dependencies.
"peerDependencies": {
"effect": "4.0.0-beta.64",
"xstate": "^5.19.2"
}import {
actorAtom,
actorRefAtom,
emittedAtom,
failureCause,
failureValue,
fromAtom,
fromEffect,
fromStream,
isFailureSnapshot,
persistedAtom,
prettyCause,
runtime,
selectAtom,
} from "@typeonce/effect-xstate";The package is ESM. The public package entrypoint is
@typeonce/effect-xstate.
fromEffect converts an Effect workflow into XState actor logic. It is useful
when a machine should invoke Effect business logic while preserving XState's
input, output, cancellation, and failure protocol.
import { Effect } from "effect";
import { assign, setup } from "xstate";
import { actorAtom, fromEffect } from "@typeonce/effect-xstate";
const checkoutMachine = setup({
actors: {
quoteLogic: fromEffect({
// Run an Effect as an invoked XState actor.
effect: ({ input }: { readonly input: { readonly total: number } }) =>
Effect.succeed({ tax: input.total * 0.22 }),
}),
},
types: {
context: {} as { readonly total: number; readonly tax: number },
},
}).createMachine({
context: { total: 100, tax: 0 },
invoke: {
src: "quoteLogic",
input: ({ context }) => ({ total: context.total }),
onDone: {
actions: assign({
tax: ({ event }) => event.output.tax,
}),
},
},
});
export const checkoutActor = actorAtom({
logic: checkoutMachine,
});The Effect receives the XState input and can return typed output for onDone.
It can also fail with typed Effect errors; error snapshots expose the underlying
Cause.
Use the emit function when the workflow should publish XState emitted events
without storing them in machine context. Stopping the invoked actor interrupts
the running Effect fiber.
fromAtom converts a readable or writable Effect Atom into XState actor logic.
It is useful when an invoked actor should observe state that already lives in the
Atom graph instead of duplicating it in machine context.
import { Atom } from "effect/unstable/reactivity";
import { assign, setup } from "xstate";
import { actorAtom, fromAtom } from "@typeonce/effect-xstate";
const quantity = Atom.make(1);
const checkoutMachine = setup({
actors: {
quantityActor: fromAtom({
// Invoke Atom state from inside a machine.
atom: quantity,
}),
},
types: {
context: {} as { readonly quantity: number },
},
}).createMachine({
context: { quantity: 0 },
invoke: {
src: "quantityActor",
onSnapshot: {
actions: assign({
quantity: ({ event }) => event.snapshot.context,
}),
},
},
});
export const checkoutActor = actorAtom({
logic: checkoutMachine,
});Readable atoms produce snapshots with the current value in context. Writable
atoms also accept atom.set events, and all atoms accept atom.refresh.
When fromAtom is invoked under an actor created by actorAtom or
actorRefAtom, it automatically uses the active AtomRegistry. For standalone
actors, pass registry when you need to share a specific Atom registry.
fromStream converts an Effect Stream into XState actor logic. It is useful
when a machine should invoke a long-running or multi-value Effect workflow and
keep each emitted value visible in the actor snapshot.
import { Stream } from "effect";
import { setup } from "xstate";
import { actorAtom, fromStream } from "@typeonce/effect-xstate";
const checkoutMachine = setup({
actors: {
pricesLogic: fromStream({
// Turn a Stream into an invoked XState actor.
stream: ({
input,
}: {
readonly input: { readonly prices: ReadonlyArray<number> };
}) => Stream.fromIterable(input.prices),
accumulation: {
mode: "reduce",
seed: 0,
reducer: (total, price) => total + price,
},
}),
},
}).createMachine({
context: { prices: [10, 20, 30] },
invoke: {
src: "pricesLogic",
input: ({ context }) => ({ prices: context.prices }),
},
});
export const checkoutActor = actorAtom({
logic: checkoutMachine,
});Stream snapshots track status, latest, count, items, value, and
result. The default accumulation mode is collect, which keeps emitted items.
Use latest to retain only the last item, none to avoid retaining emitted
items, or reduce to maintain a custom accumulator. Stopping the invoked actor
interrupts the running Stream fiber.
actorAtom wraps XState actor logic as a writable Effect Atom whose value is
the current snapshot. actorRefAtom exposes the live XState Actor when another
atom, integration, or inspection tool needs direct actor access.
import { AtomRegistry } from "effect/unstable/reactivity";
import { assign, setup } from "xstate";
import { actorAtom, actorRefAtom } from "@typeonce/effect-xstate";
const counterMachine = setup({
types: {
context: {} as { readonly count: number },
events: {} as { readonly type: "counter.increment" },
},
}).createMachine({
context: { count: 0 },
on: {
"counter.increment": {
actions: assign({
count: ({ context }) => context.count + 1,
}),
},
},
});
export const counterActor = actorAtom({
// Expose the machine snapshot as a writable Atom.
logic: counterMachine,
});
export const counterRef = actorRefAtom({
// Expose the live XState actor as an Atom.
logic: counterMachine,
});
const registry = AtomRegistry.make();
registry.get(counterActor);
registry.set(counterActor, { type: "counter.increment" });
registry.get(counterRef).send({ type: "counter.increment" });actorAtom starts lazily when read by an AtomRegistry, sends events when the
atom is written to, and stops the actor when the registry finalizes it.
Use actorRefAtom sparingly for lower-level integrations that need actor
methods such as send, subscribe, or getPersistedSnapshot. For component
state, prefer actorAtom and derived atoms.
emittedAtom exposes XState emitted events as an Effect Atom. It is useful for
side channels such as telemetry, notifications, or domain events that should not
be stored as durable machine context.
import { Option } from "effect";
import { emittedAtom } from "@typeonce/effect-xstate";
export const completedAtom = emittedAtom({
// Subscribe to emitted events from an actorAtom.
actor: checkoutActor,
type: "checkout.completed",
});
const latestCompleted = Option.match(registry.get(completedAtom), {
onNone: () => undefined,
onSome: (event) => event,
});The returned atom starts as Option.none() and becomes Option.some(event)
after a matching emitted event is observed.
Pass a concrete emitted event type to select one channel, or pass "*" to
observe all emitted events supported by the actor logic.
persistedAtom exposes actor.getPersistedSnapshot() as an Effect Atom. It is
useful when persistence or hydration code should react to XState's persisted
snapshot shape from inside the Atom graph.
import { persistedAtom } from "@typeonce/effect-xstate";
export const persistedCheckoutAtom = persistedAtom({
// Project XState's persisted snapshot into Atom.
actor: checkoutActor,
});
const persisted = registry.get(persistedCheckoutAtom);This helper delegates to XState's persisted snapshot API directly. It does not add encoding, decoding, storage, or restoration behavior.
The atom updates on actor snapshot changes, completion, and error. Persist the returned value using the storage layer that fits your application.
selectAtom creates an Effect Atom projection over an actorAtom snapshot. It
is useful when UI or domain atoms only need a stable derived value instead of the
whole XState snapshot.
import { selectAtom } from "@typeonce/effect-xstate";
export const canSubmitAtom = selectAtom({
// Derive a focused Atom from the actor snapshot.
actor: checkoutActor,
selector: (snapshot) =>
snapshot.matches("editing") && snapshot.context.items.length > 0,
});Selectors run against the live actor snapshot and update the derived atom only
when the selected value changes. By default, changes are compared with
Object.is.
Pass equal for custom equality when the selected value is an object, array, or
other structure that should avoid unnecessary updates.
runtime wraps Atom.runtime(layer) with XState helpers. It is useful when
invoked fromEffect or fromStream actors need services from an Effect layer
without mutating global Effect or XState objects.
import { Context, Effect, Layer } from "effect";
import { Atom } from "effect/unstable/reactivity";
import { assign, setup } from "xstate";
import { fromEffect, runtime as xstateRuntime } from "@typeonce/effect-xstate";
class PricingService extends Context.Service<
PricingService,
{
readonly quote: (quantity: number) => Effect.Effect<number>;
}
>()("app/PricingService") {}
const PricingLive = Layer.succeed(
PricingService,
PricingService.of({
quote: (quantity) => Effect.succeed(quantity * 12),
})
);
const appRuntime = xstateRuntime(Atom.runtime(PricingLive));
const checkoutMachine = setup({
actors: {
quoteLogic: fromEffect({
// Use a service provided by Atom.runtime(PricingLive).
effect: ({ input }: { readonly input: { readonly quantity: number } }) =>
Effect.gen(function* () {
const pricing = yield* PricingService;
return yield* pricing.quote(input.quantity);
}),
}),
},
types: {
context: {} as { readonly quantity: number; readonly total: number },
},
}).createMachine({
context: { quantity: 2, total: 0 },
invoke: {
src: "quoteLogic",
input: ({ context }) => ({ quantity: context.quantity }),
onDone: {
actions: assign({
total: ({ event }) => event.output,
}),
},
},
});
export const appActor = appRuntime.actorAtom({
// Create an actorAtom backed by the Atom runtime.
logic: checkoutMachine,
});
const standaloneActor = appRuntime.createActor({
// Create a standalone XState actor with the same runtime.
logic: checkoutMachine,
});Use appRuntime.actorAtom or appRuntime.actorRefAtom when the actor should be
owned by an Atom registry and use the runtime services. Use
appRuntime.createActor for standalone XState actors backed by the same runtime.
Runtime-backed fromEffect and fromStream invocations wait for the Atom
runtime and include runtime failures in error snapshots. The actor-system bridge
is scoped to the actor and cleaned up when the actor stops.
The remaining exported functions help inspect Cause-backed error snapshots from
fromEffect, fromStream, and fromAtom. They are useful when an invoked actor
fails and you need the original Effect Cause for logging, diagnostics, or
typed error handling.
import { Effect } from "effect";
import { createActor, setup, waitFor } from "xstate";
import {
failureCause,
failureValue,
fromEffect,
isFailureSnapshot,
prettyCause,
} from "@typeonce/effect-xstate";
type QuoteError = {
readonly _tag: "QuoteError";
readonly message: string;
};
const checkoutMachine = setup({
actors: {
quoteLogic: fromEffect({
// Fail with a typed Effect error stored in the actor snapshot Cause.
effect: () =>
Effect.fail({
_tag: "QuoteError",
message: "Quantity is no longer available",
} as const),
}),
},
}).createMachine({
invoke: {
id: "quote",
src: "quoteLogic",
},
});
const actor = createActor(checkoutMachine).start();
const quoteActor = actor.getSnapshot().children.quote;
const snapshot = await waitFor(quoteActor, (snapshot) => {
// Wait until the child actor reaches a Cause-backed error snapshot.
return isFailureSnapshot<QuoteError>(snapshot);
});
if (isFailureSnapshot(snapshot)) {
// Return the Effect Cause stored on the error snapshot.
const cause = failureCause(snapshot);
// Read the original Effect Cause for diagnostics or structured handling.
console.error(cause);
// Extract the first typed failure value from the Cause, when present.
const error = failureValue(snapshot);
// Format the Cause into a human-readable message for logs.
console.error(prettyCause(cause));
if (error?._tag === "QuoteError") {
console.error(error.message);
}
}isFailureSnapshot only checks for status: "error", so use it on snapshots
from actors that expose Effect causes. failureCause returns snapshot.cause
when available and falls back to snapshot.error, while failureValue is most
useful for typed Effect.fail(...) values.
prettyCause delegates to Effect's Cause formatter. Keep the raw Cause when you
need structured data, and format it only at logging or display boundaries.
The React Vite example lives in examples/react-vite and demonstrates these
APIs with @effect/atom-react.