Skip to content

State machines and effect

State machines in effect is a question of time. I am working from multiple angles to ensure this will come, soon, sooner. Here are the latest updates of what's happening.

Contents
  1. Native state machines with effect-machine
  2. xstate/effect integration package

State machines are (will) come to effect, one way or another πŸ’πŸΌβ€β™‚οΈ

I am working in multiple ways to make this become a reality, soon(er)

As effect-machine gets better, also xstate is moving to better support effect.

Here is a glimpse of the future πŸ‘€


Native state machines with effect-machine

@typeonce/effect-machine is getting more stable, but not yet there πŸ”œ

One month ago I shared a turnstile example of the API (newsletter link here):

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

export const TurnstileState = Schema.TaggedUnion({
  Locked: {},
  Unlocked: {}
})

export const TurnstileEvent = Schema.TaggedUnion({
  CoinInserted: {},
  GatePushed: {}
})

export const TurnstileStates = Machine.defineStates(TurnstileState.cases)

export const TurnstileMachine = Machine.make({
  id: "Turnstile",
  states: TurnstileStates.states,
  events: [TurnstileEvent],
  initial: () => TurnstileStates.initial.Locked.from()
}).handle({
  Locked: {
    on: {
      CoinInserted: ({ target }) => target.full.Unlocked.from()
    }
  },
  Unlocked: {
    on: {
      GatePushed: ({ target }) => target.full.Locked.from()
    }
  }
})

This was good (if you ask me), but there was margin to make it more concise, with similar expressiveness and no compromise.

Here is how this looks today, as of v0.33.0:

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

export const TurnstileEvent = Machine.events({
  CoinInserted: {},
  GatePushed: {}
})

export const TurnstileMachine = Machine.make({
  id: "Turnstile",
  events: TurnstileEvent,
  root: Machine.state({
    initial: "Locked",
    states: {
      Locked: {},
      Unlocked: {}
    }
  })
}).handle({
  states: {
    Locked: {
      on: {
        CoinInserted: (to) => to.local.Unlocked()
      }
    },
    Unlocked: {
      on: {
        GatePushed: (to) => to.local.Locked()
      }
    }
  }
})

A few details to notice in the new version:

  • No need to import Schema, built-in in events and state
  • Root is explicit, no need defineStates and .states
  • initial: "Locked" replaces the initialization callback
  • .from() is not necessary

These changes make simpler day-to-day use cases easier to write and read ✍️

A few more examples:

/// Before
const States = Machine.defineStates({
  Checkout: {
    schema: State.cases.Checkout,
    initial: "Shipping",
    states: {
      Shipping: { type: "choice" },
      Free: State.cases.Free,
      Paid: State.cases.Paid
    }
  }
})

Machine.make({
  states: States.states,
  events: [],
  initial: () =>
    States.initial.Checkout(checkout, (checkout) =>
      checkout.Shipping()
    )
}).handle({
  Checkout: {
    states: {
      Shipping: {
        choice: {
          transition: ({ parent, target }) =>
            parent.total >= 100
              ? target.local.Free(free)
              : target.local.Paid(paid)
        }
      }
    }
  }
})

// After
const Root = Machine.state({
  schema: State.cases.Checkout,
  initial: "Shipping",
  states: {
    Shipping: { type: "choice" },
    Free: State.cases.Free,
    Paid: State.cases.Paid
  }
})

Machine.make({
  root: Root,
  events: Machine.events({}),
  initial: (root) => root.decoded(() => checkout)
}).handle({
  states: {
    Shipping: {
      choice: (to) => to.branches({
        free: { target: to.local.Free() },
        paid: { target: to.local.Paid() }
      }).resolve(({ containingState, select }) =>
        containingState.total >= 100
          ? select.free.decoded(free)
          : select.paid.decoded(paid)
      )
    }
  }
})
// Before
export const CounterMachine = Machine.make({
  id: "Counter",
  states: States.states,
  events: [Event],
  internalEvents: [InternalEvent],
  initial: () => States.initial.Count.from({ value: 0 })
}).handle({
  Count: {
    on: {
      Increment: ({ state, target }) =>
        target.full.Count.from({ value: state.value + 1 }),

      Decrement: ({ state, target }) =>
        target.full.Count.from({ value: state.value - 1 }),

      Save: ({ state, target }) =>
        target.full.Saving.from({ value: state.value })
    }
  },

  Saving: {
    invoke: ({ state }) => SaveCount(state.value),

    on: {
      Saved: ({ event, target }) =>
        target.full.Count.from({ value: event.value })
    }
  }
})

// After
export const CounterMachine = Machine.make({
  id: "Counter",
  root: Machine.state({
    fields: { value: Schema.Number },
    initial: "Count",
    states: { Count: {}, Saving: {} }
  }),
  events: Machine.events({ Increment: {}, Decrement: {}, Save: {} }),
  initial: (root) => root.from(() => ({ value: 0 }))
}).handle({
  states: {
    Count: {
      on: {
        Increment: (to) => to.root.update
          .from(({ current }) => ({ value: current.value + 1 })),
        Decrement: (to) => to.root.update
          .from(({ current }) => ({ value: current.value - 1 })),
        Save: (to) => to.local.Saving()
      }
    },
    Saving: {
      invoke: (from) => from.effect("save-count", ({ containingState }) =>
        SaveCount(containingState.value)
      ).onDone((to) => 
        to.local.Count().updating(to.root).from(({ output }) => ({
          target: undefined,
          update: { value: output }
        }))
      )
    }
  }
})

The model works, internals are clean and inline with effect internal patterns.

The goal remains: make this become part of effect core πŸ”œ

xstate/effect integration package

The other side of the work is making xstate integrate with effect.

A package is work-in-progress for this: @xstate/effect

I may (or may not) be helping making this package as good as possible.

The code already looks a lot xstate-like, but with effect:

fromEffect(Effect.succeed('done'));

fromEffect(({ input }: { input: string }) => Effect.succeed(input.length));

fromEffect({
  id: 'loadUser',
  schemas: {
    input: Schema.Struct({ id: Schema.String }),
    output: Schema.Struct({ id: Schema.String })
  },
  effect: ({ input }) => Api.use((api) => api.fetchUser(input.id))
});

// ...

import { Effect, Schema } from 'effect';
import { setupEffect } from '@xstate/effect';

const machine = setupEffect({
  schemas: {
    context: Schema.Struct({ count: Schema.Number }),
    events: {
      ADD: Schema.Struct({ value: Schema.Number })
    }
  }
}).createMachine({
  context: { count: 0 },
  on: {
    ADD: ({ context, event }) => ({
      context: { count: context.count + event.value }
    })
  }
});

This is standard xstate v6, but with sprinkles of effect where is matters:

  • Schema from effect
  • Actors spawned as effects
  • Effect dependencies and runtime

This is another promising direction. It taps into the full XState ecosystem and tooling.

I am still convinced we will get state machines natively in effect (from effect-machine or otherwise). But, meanwhile, the XState integration looks really satisfying πŸͺ„


The interest in state machines is growing (or is it just my feed?). In any case, state machines in effect soon, XState v6 soon, and improvements keep coming πŸ‘‡

See you next πŸ‘‹