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 ineventsandstate - Root is explicit, no need
defineStatesand.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
effectcore ๐
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
๐๏ธ Working on xstate/effect XState handles type-safe state-machine logic Effect makes the effectful boundary "type-safer" with typed failures, reqs, schemas, structured concurrency, and more Goal: run existing XState machines in Effect w/ minimal changes Follow along: ย Show more
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 ๐
effect-machine now has explicit root state More concise and simple API, easier to read, and shorter for most use cases ๐
See you next ๐
