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.


Software
2 min read
On this page

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 ๐Ÿ‘‹

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.