Skip to content

Effect state machines, just like XState

Iteration after iteration, the effect-machine API reached a more stable API. And, guess what, it looks really close to XState. Let's see an example then.


Software
2 min read
On this page

@typeonce/effect-machine made another leap forward, in the direction of XState πŸ’πŸΌβ€β™‚οΈ

The runtime is all effect, but the machine API converged towards a XState (after all) πŸ™Œ

An example is always the best way to understand, here we go πŸ‘‡


Machine topology

The setup starts from Schema.

In effect-machine, those are events and state:

  • Machine.events: what you can send to the machine
  • Machine.state: topology (structure) of the machine

Note that all machines have a root state (it works similar to context in XState).

import { Machine } from "@typeonce/effect-machine";
import { Schema } from "effect";

// Events that you can send to the machine
export const CopyEvents = Machine.events({
  Copy: { text: Schema.String },
});

// Topology of the machine
export const CopyStates = Machine.state({
  // πŸ‘ˆ Implicit root state
  states: {
    Idle: {},
    Copied: {},
    // πŸ‘‡ State definition with state-specific value (from schema) 
    Working: {
      fields: { text: Schema.String },
      // Nested states
      states: {
        Clicked: {},
        Copying: {},
      },
    },
    Failed: {
      fields: { message: Schema.String },
    },
  },
});

A key recent change: initial is not part anymore of the machine topology πŸ‘€

That belongs to the machine definition, later πŸ”œ

Type-safe targets

First key API difference with XState: Machine.targets

effect-machine extracts type-safe targets, instead of using string πŸ‘€

Each state of the machine has a path (internally), with all sort of metadata attached to it.

effect-machine can derive type-safe targets from the state definition above:

export const CopyStates = Machine.state({ /* ... */ })
const targets = Machine.targets(CopyStates);

Machine setup

Building the machine uses the Machine.make API (effect-like naming):

export const copyMachine = Machine.make({
  id: "CopyButton",
  root: CopyStates, // πŸ‘ˆ From `Machine.state`
  events: CopyEvents, // πŸ‘ˆ From `Machine.events`
})

As part of Machine.make, you then define all the machine parameters, which includes things like effects and timers:

export const copyMachine = Machine.make({
  id: "CopyButton",
  root: CopyStates,
  events: CopyEvents,
  effects: {
    copy: copyText, // πŸ‘ˆ `copyText` is just an `Effect`
  },
  timers: {
    // πŸ‘‡ Effect's `Duration`
    clicked: "120 millis",
    confirmation: "2 seconds",
    errorFeedback: "1 second",
  },
})

This setup makes all references and handlers type safe for the next step πŸ‘‡

Machine definition

All the types and setup work is done.

From Machine.make, you chain .handle to construct the machine execution model:

export const copyMachine = Machine.make({
  id: "CopyButton",
  root: CopyStates,
  events: CopyEvents,
  effects: {
    copy: copyText,
  },
  timers: {
    clicked: "120 millis",
    confirmation: "2 seconds",
    errorFeedback: "1 second",
  },
}).handle({
  // ...
})

The model is where the similarity with XState really appears:

  • initial state
  • states (with autocomplete)
  • on transition handlers
  • invoke for executing effects (and more)
}).handle({
  initial: { target: targets.root.Idle },
  states: {
    Idle: {
      on: {
        Copy: {
          target: targets.root.Working,
          guard: ({ event }) => event.text.length > 0,
          data: ({ event }) => ({ text: event.text }),
        },
      },
    },
    Working: {
      initial: { target: targets.root.Working.Clicked },
      invoke: {
        src: "copy",
        input: ({ state }) => state.text,
        onDone: { target: targets.root.Copied },
        onFailure: {
          target: targets.root.Failed,
          data: ({ error }) => ({
            message: error._tag === "TimeoutError"
              ? "The clipboard did not respond within 5 seconds. Try again."
              : error.message,
          }),
        },
      },
      states: {
        Clicked: {
          invoke: {
            src: "clicked",
            onDone: { target: targets.root.Working.Copying },
          },
        },
        Copying: {},
      },
    },
    Copied: {
      invoke: {
        src: "confirmation",
        onDone: { target: targets.root.Idle },
      },
    },
    Failed: {
      invoke: {
        src: "errorFeedback",
        onDone: { target: targets.root.Idle },
      },
      on: {
        Copy: {
          target: targets.root.Working,
          guard: ({ event }) => event.text.length > 0,
          data: ({ event }) => ({ text: event.text }),
        },
      },
    },
  },
})

But, there are also a few key differences, unlocked by the effect model (and my judgement).

Inside invoke, onDone and onFailure are required, based on the Effect<A, E, R> type πŸ™Œ

If A is not never, onDone required. If E is not never, onFailure is required.

This makes handlers explicit: you (or an agent) need to manually ignore a onDone or onFailure transition πŸ’πŸΌβ€β™‚οΈ

onFailure carries the type error from the invoked Effect πŸͺ„

Handle typed errors inside Effect<A, E, R>, and get error: E inside onFailure.

Interruption is handled by effect by default πŸͺ„

No need to carry a signal/AbortController. The effect's interruption model is all you need, and it will work for you automatically.


The result is an API that looks like xstate, but runs with effect.

You don't need any bridge between your effect app and state machines, you just provide effects, the machine understands them and works with you 🀝


We are at the N iteration on the effect-machine API (where N is a big number) πŸ’πŸΌβ€β™‚οΈ

The similarity with XState is not an accident (more or less): XState is a major inspiration, and its API shows how well designed the library is πŸͺ„

See you next πŸ‘‹

Get next Wednesday’s issue

The next focused idea arrives by email. Free, and easy to leave at any time.

Free, no spam, unsubscribe anytime. See the privacy policy.