Skip to content

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 assignment
assign({ count: 0 })
// Dynamic assignment using context
assign(({ context }) => ({
count: context.count + 1,
}))
// Dynamic assignment using event data
assign(({ 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 logging
effect(() => Effect.log("Entered state"))
// Using context
effect(({ 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 event
raise(new Done())
// Dynamic event based on context
raise(({ 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 event
emit({ type: "notification", message: "Task completed" })
// Dynamic event
emit(({ 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 message
log("Entered active state")
// Dynamic message
log(({ context }) => `Count is now ${context.count}`)

sendTo

Sends an event to a child machine by ID.

import { sendTo } from "effstate";
// Static target and event
sendTo("childMachine", new StartEvent())
// Dynamic target
sendTo(({ context }) => `child-${context.selectedId}`, new StartEvent())
// Dynamic event
sendTo("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 event
sendParent(new ChildCompleted({ result: "success" }))
// Dynamic event
sendParent(({ context }) => new ResultReady({ data: context.result }))

forwardTo

Forwards the current event to a child machine.

import { forwardTo } from "effstate";
// Forward to static target
forwardTo("childMachine")
// Forward to dynamic target
forwardTo(({ context }) => `child-${context.activeChildId}`)

spawnChild

Dynamically creates a child machine actor.

import { spawnChild } from "effstate";
// Static ID
spawnChild(ChildMachine, { id: "myChild" })
// Dynamic ID
spawnChild(ChildMachine, {
id: ({ context }) => `child-${context.items.length}`,
})

stopChild

Stops a child machine by ID.

import { stopChild } from "effstate";
// Static ID
stopChild("myChild")
// Dynamic ID
stopChild(({ event }) => event.childId)

cancel

Cancels a pending delayed transition by its ID.

import { cancel } from "effstate";
// Cancel by static ID
cancel("myDelay")
// Cancel by dynamic ID
cancel(({ 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:

  1. Exit actions of the source state
  2. Transition actions
  3. Entry actions of the target state