XState Effect: Schemas and actions
Type workflow data and run background work with setupEffect.
XState v6 is in alpha
setupEffect is the Effect-aware form of XState's setup.
- Declare input, context, events and output with Effect schemas.
- Register actions that return Effects.
- Register actors, guards and delays as you would with
setup. - Start the machine with
createEffectActor.
Schemas and background actions
This machine accepts a typed approval event and records an audit entry without blocking its transition:
import { Context, Effect, Latch, Schema } from 'effect';
import { createEffectActor, send, setupEffect, waitFor } from '@xstate/effect';
import { standardSchemaValidator } from 'xstate/validation';
class Audit extends Context.Service<
Audit,
{ readonly record: (reviewer: string) => Effect.Effect<void> }
>()('@app/Audit') {}
const reviewMachine = setupEffect({
schemas: { events: { APPROVE: Schema.Struct({ reviewer: Schema.String }) } },
actions: {
audit: ({ event }) => Audit.use((audit) => audit.record(event.reviewer))
}
}).createMachine({
validator: standardSchemaValidator(),
initial: 'pending',
states: {
pending: {
on: {
APPROVE: (args, enq) => {
enq(args.actions.audit, args);
return { target: 'approved' };
}
}
},
// Keep the actor alive while its background audit runs.
approved: {}
}
});
const program = Effect.gen(function* () {
const recorded = yield* Latch.make();
const actor = yield* createEffectActor(reviewMachine).pipe(
Effect.provideService(Audit, { record: () => Effect.asVoid(recorded.open) })
);
yield* send(actor, { type: 'APPROVE', reviewer: 'Ada' });
yield* waitFor(actor, (s) => s.matches('approved'));
// Wait for the demo audit before closing the scope.
yield* recorded.await;
return actor.getSnapshot().value;
});
export const result = await Effect.runPromise(Effect.scoped(program));
console.log(result); // 'approved'The latch lets this demo wait for the audit before closing its scope. In an application, keep the actor alive for the workflow's lifetime.
Effect schemas
setupEffect accepts Effect schemas in schemas, per-state states declarations and extend.
- Types come from the decoded
Schema.Type; no manual conversion is needed. - Per-state schemas narrow context with
taggedState. - Standard Schemas can be mixed with Effect schemas.
EffectSchemaLikerepresents either kind. fromEffectalso accepts Effect input and output schemas.
Runtime validation
Add standardSchemaValidator() from xstate/validation to check values at runtime, as above. Without a validator, schemas provide types only.
Runtime schema constraints
XState checks values but does not replace them with decoded values. With runtime validation enabled, encoded and decoded types must match; transforming schemas such as Schema.NumberFromString are rejected by TypeScript. Schemas must decode synchronously, without service requirements.
Choose an action or an actor
- Use an Effect action for background work: audit entries, logging, telemetry or notifications. The transition commits without waiting for it.
- Use an invoked Effect actor when success or failure decides the next state.
fromEffectgivesonDoneandonErrortyped results and interrupts the task when its invoking state exits.
An Effect action runs in the actor's Effect context. Its failures and defects route to the current state's onError; stopping the actor interrupts it without reporting an error.
Action execution and completion
The action function is called synchronously during the transition. The returned Effect runs after the transition commits, in a forked fiber, without blocking subsequent events.
- The transition enqueues the action with explicit arguments:
enq(args.actions.audit, args). EffectActionArgsandEffectActionname the argument and action types.- An Effect action cannot use the transition's enqueue API after the transition. It can call
self.sendto send a new event. - A final state or a closing scope can interrupt pending background work. Invoke a task when the workflow must wait for completion.
Provide an action
Use machine.provide({ actions }) to replace an action for a particular environment. The provided machine requires the replacement's services. In this example, an audit implementation adds the Audit service:
import { Context, Effect, Latch } from 'effect';
import { createEffectActor, send, setupEffect } from '@xstate/effect';
class Audit extends Context.Service<Audit, { record: Effect.Effect<void> }>()(
'Audit'
) {}
const recorded = Latch.makeUnsafe();
const machine = setupEffect({
actions: { audit: (_args) => Effect.void }
}).createMachine({
on: { APPROVE: (args, enq) => enq(args.actions.audit, args) }
});
const auditedMachine = machine.provide({
actions: { audit: () => Audit.use((audit) => audit.record) }
});
const program = Effect.gen(function* () {
// The override adds Audit to this machine's required services.
const actor = yield* createEffectActor(auditedMachine);
yield* send(actor, { type: 'APPROVE' });
yield* recorded.await;
return 'approval recorded';
});
export const result = await Effect.runPromise(
program.pipe(
Effect.scoped,
Effect.provideService(Audit, { record: Effect.asVoid(recorded.open) })
)
);
console.log(result); // approval recorded- Other actions and their service requirements stay in place.
- Replacing the audit with an action that requires a different service updates the required service type again.
- A plain action can replace an Effect action and remove its service requirement.
Declared sources
Declare Effect actions and spawned Effect actors so their services contribute to requirement inference.
- Register actions with
setupEffect({ actions }), then enqueue the named action. - Register spawned logic in
actors, then useenq.spawn(args.actors.worker). - Inline
invoke.srclogic is supported and contributes requirements; declaring it inactorsalso gives it a reusable name.
Inline action and spawn pitfalls
An inline callback such as enq(() => Effect.log('saved')) creates an Effect that is discarded. Inline Effect logic passed to enq.spawn is rejected at runtime because its service requirements cannot be inferred.
The repository's xstate-effect/no-inline-effect oxlint rule checks these two patterns. It recognizes the root identifier Effect; Effects returned by helper functions are outside that check.
To use the plugin elsewhere, install @oxlint/plugins, copy scripts/oxlint-plugin-xstate-effect.ts and configure it in jsPlugins. Loading the TypeScript source requires Node.js 22.18+ or a newer release with native TypeScript support.