@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 machineMachine.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:
initialstatestates(with autocomplete)ontransition handlersinvokefor executingeffects(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,onDoneandonFailureare required, based on theEffect<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 ππΌββοΈ
onFailurecarries the type error from the invokedEffectπͺ
Handle typed errors inside Effect<A, E, R>, and get error: E inside onFailure.
Interruption is handled by
effectby 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 π
