Actions
Actions are side effects that run during state transitions. They can update context, run Effects, communicate with other machines, and more.
assign
Updates the machine’s context. Can be static or dynamic.
import { assign } from "effstate";
// Static assignmentassign({ count: 0 })
// Dynamic assignment using contextassign(({ context }) => ({ count: context.count + 1,}))
// Dynamic assignment using event dataassign(({ context, event }) => ({ count: context.count + event.amount,}))Usage in a state:
states: { idle: { on: { INCREMENT: { actions: [ assign(({ context }) => ({ count: context.count + 1 })), ], }, }, },}effect
Runs an Effect for side effects like logging, API calls, or any async operation.
import { effect } from "effstate";import { Effect } from "effect";
// Simple loggingeffect(() => Effect.log("Entered state"))
// Using contexteffect(({ context }) => Effect.log(`Count is ${context.count}`))
// Call an API (within Effect.Service)effect(({ context }) => api.saveProgress(context.count))With dependencies:
export class MyMachine extends Effect.Service<MyMachine>()("MyMachine", { effect: Effect.gen(function* () { const api = yield* ApiClient;
const machine = createMachine({ states: { saving: { entry: [ effect(({ context }) => api.save(context.data)), ], }, }, });
return { machine, createActor: () => interpret(machine) }; }), dependencies: [ApiClient.Default],}) {}raise
Sends an event to the machine itself. Useful for triggering follow-up transitions.
import { raise } from "effstate";import { Data } from "effect";
class Done extends Data.TaggedClass("DONE")<{}> {}
// Static eventraise(new Done())
// Dynamic event based on contextraise(({ context }) => new UpdateCount({ value: context.count * 2 }))Example: Auto-transition after action:
states: { processing: { entry: [ assign({ status: "done" }), raise(new ProcessingComplete()), ], on: { PROCESSING_COMPLETE: { target: "idle" }, }, },}emit
Emits events to external listeners registered via actor.on(). Unlike raise, these events don’t trigger transitions.
import { emit } from "effstate";
// Static eventemit({ type: "notification", message: "Task completed" })
// Dynamic eventemit(({ context }) => ({ type: "countChanged", count: context.count,}))Listening externally:
const actor = yield* interpret(machine);
actor.on("notification", (event) => { console.log(event.message);});
actor.on("countChanged", (event) => { analytics.track("count_changed", { count: event.count });});log
Shorthand for logging messages.
import { log } from "effstate";
// Static messagelog("Entered active state")
// Dynamic messagelog(({ context }) => `Count is now ${context.count}`)sendTo
Sends an event to a child machine by ID.
import { sendTo } from "effstate";
// Static target and eventsendTo("childMachine", new StartEvent())
// Dynamic targetsendTo(({ context }) => `child-${context.selectedId}`, new StartEvent())
// Dynamic eventsendTo("childMachine", ({ context }) => new SetValue({ value: context.value }))sendParent
Sends an event to the parent machine (when this machine is a child).
import { sendParent } from "effstate";
// Static eventsendParent(new ChildCompleted({ result: "success" }))
// Dynamic eventsendParent(({ context }) => new ResultReady({ data: context.result }))forwardTo
Forwards the current event to a child machine.
import { forwardTo } from "effstate";
// Forward to static targetforwardTo("childMachine")
// Forward to dynamic targetforwardTo(({ context }) => `child-${context.activeChildId}`)spawnChild
Dynamically creates a child machine actor.
import { spawnChild } from "effstate";
// Static IDspawnChild(ChildMachine, { id: "myChild" })
// Dynamic IDspawnChild(ChildMachine, { id: ({ context }) => `child-${context.items.length}`,})stopChild
Stops a child machine by ID.
import { stopChild } from "effstate";
// Static IDstopChild("myChild")
// Dynamic IDstopChild(({ event }) => event.childId)cancel
Cancels a pending delayed transition by its ID.
import { cancel } from "effstate";
// Cancel by static IDcancel("myDelay")
// Cancel by dynamic IDcancel(({ context }) => `delay-${context.id}`)With delayed transitions:
states: { waiting: { after: { 5000: { id: "timeout", // Give the delay an ID target: "timedOut", }, }, on: { CANCEL: { target: "idle", actions: [cancel("timeout")], // Cancel before it fires }, }, },}Delayed Transitions
The after property on a state configures delayed transitions. By default, delays are auto-cancelled when exiting the state.
Basic Usage
import { Duration } from "effect";
states: { loading: { after: { delay: Duration.seconds(30), transition: { target: "timeout" }, }, on: { SUCCESS: { target: "ready" }, // Cancels the delay automatically }, },}Persistent Delays
Use persistent: true for delays that should survive state exits (e.g., session timeouts):
states: { active: { after: { delay: Duration.minutes(30), transition: { target: "sessionExpired" }, persistent: true, // Fires even if user navigates to other states }, on: { NAVIGATE: { target: "otherState" }, // Delay keeps running! }, },}Persistent delays can still be explicitly cancelled:
states: { active: { after: { delay: Duration.minutes(30), transition: { target: "sessionExpired", id: "sessionTimeout" }, persistent: true, }, on: { REFRESH_SESSION: { actions: [cancel("sessionTimeout")], // Explicit cancellation }, }, },}Effect-Based Delays
For full control, use an Effect instead of a Duration:
import { Effect, Duration } from "effect";
states: { waiting: { after: { delay: Effect.sleep(Duration.seconds(5)).pipe( Effect.onInterrupt(() => telemetry.track("delay_cancelled") ) ), transition: { target: "done" }, }, },}Complex multi-step delays:
after: { delay: Effect.gen(function* () { yield* Effect.sleep(Duration.seconds(30)); yield* metrics.recordWarning("approaching timeout"); yield* Effect.sleep(Duration.seconds(30)); }), transition: { target: "timeout" },}enqueueActions
Dynamically build an action list at runtime based on conditions.
import { enqueueActions } from "effstate";
enqueueActions(({ context, event, enqueue }) => { // Always reset count enqueue.assign({ count: 0 });
// Conditionally add more actions if (context.count > 10) { enqueue.assign({ status: "high" }); }
if (event.notify) { enqueue.effect(() => Effect.log("Notifying...")); }
// Raise follow-up event enqueue.raise(new ProcessingDone());})Typed Invoke Helpers
Special assign helpers for invoke handlers with proper typing:
assignOnSuccess
import { assignOnSuccess, invoke } from "effstate";
invoke: invoke({ src: () => fetchUser(), onSuccess: { target: "ready", actions: [ assignOnSuccess<MyContext, User>(({ context, output }) => ({ user: output, loading: false, })), ], },})assignOnFailure
import { assignOnFailure, invoke } from "effstate";
invoke: invoke({ src: () => fetchUser(), onFailure: { target: "error", actions: [ assignOnFailure<MyContext, ApiError>(({ context, error }) => ({ errorMessage: error.message, })), ], },})assignOnDefect
import { assignOnDefect, invoke } from "effstate";
invoke: invoke({ src: () => fetchUser(), onDefect: { target: "crashed", actions: [ assignOnDefect<MyContext>(({ context, defect }) => ({ errorMessage: `Unexpected: ${String(defect)}`, })), ], },})Action Order
Actions run in the order they’re defined:
on: { SUBMIT: { target: "submitting", actions: [ assign({ status: "submitting" }), // 1. Update context effect(() => Effect.log("Submitting")), // 2. Log emit({ type: "formSubmitted" }), // 3. Notify listeners ], },}Entry and exit actions run in this order during transitions:
- Exit actions of the source state
- Transition actions
- Entry actions of the target state