Transitions
Transitions bring state-machines to life, defining when and how they move from state-to-state.
Example: connection machine
We will expand on the “connection machine” example we used when explaining states and events, to illustrate various transitions.
Definition
Here is the complete machine source. In the following sections we explore the various different transition techniques it uses.
import { defineMachine } from "yay-machine";import type { Connection, Transport } from "./Transport";
interface StateData { readonly maxReconnectionAttempts: number; readonly log: (message: string) => void; readonly transport: Transport; readonly onReceive: (data: string) => void; readonly lastHeartbeatTime: number;}
interface DisconnectedState extends StateData { readonly name: "disconnected";}
interface ConnectionAttemptState { readonly url: string; readonly connectionAttemptNum: number;}
interface ConnectingState extends StateData, ConnectionAttemptState { readonly name: "connecting";}
interface ReattemptConnectionState extends StateData, ConnectionAttemptState { readonly name: "reattemptConnection";}
interface ConnectedState extends StateData, ConnectionAttemptState { readonly name: "connected"; readonly connectionId: string; readonly connection: Connection;}
interface ConnectionErrorState extends StateData, ConnectionAttemptState { readonly name: "connectionError"; readonly errorMessage: string;}
type ConnectionState = | DisconnectedState | ConnectingState | ReattemptConnectionState | ConnectedState | ConnectionErrorState;
type ConnectionEvent = | { readonly type: "CONNECT"; readonly url: string } | { readonly type: "CONNECTED"; readonly connectionId: string; readonly connection: Connection; } | { readonly type: "SEND"; readonly data: string } | { readonly type: "ERROR"; readonly code: number; readonly errorMessage: string; } | { readonly type: "HEARTBEAT" } | { readonly type: "DISCONNECT" } | { readonly type: "DISCONNECTED" };
const isAuthError = (code: number) => code === 401 || code === 403;
/** * Models a client connection via a fictitious "transport / connection" API. * Demonstrates various transitions configurations: * - specific state + event * - any state + event * - immediate * - conditionals, single and multi * - `reenter:false` */export const connectionMachine = defineMachine< ConnectionState, ConnectionEvent>({ initialState: { name: "disconnected", maxReconnectionAttempts: 10, log: undefined!, // provided at runtime, per machine-instance transport: undefined!, // provided at runtime, per machine-instance onReceive: undefined!, // provided at runtime, per machine-instance lastHeartbeatTime: -1, }, states: { disconnected: { on: { CONNECT: { to: "connecting", data: ({ state, event: { url } }) => ({ ...state, url, connectionAttemptNum: 1, }), }, }, }, connecting: { onEnter: ({ state: { log, transport, url }, send }) => { log(`connecting to ${url}`); const connection = transport.connect(url); connection.onconnect = (connectionId) => send({ type: "CONNECTED", connectionId, connection }); connection.onerror = (error) => send({ type: "ERROR", ...error }); }, on: { CONNECTED: { to: "connected", data: ({ state, event: { connectionId, connection } }) => ({ ...state, connectionId, connection, }), }, ERROR: [ { to: "connectionError", when: ({ event }) => isAuthError(event.code), data: ({ state, event: { errorMessage } }) => ({ ...state, errorMessage, }), }, { to: "reattemptConnection", data: ({ state }) => state, }, ], }, }, connected: { onEnter: ({ state: { log, url, connection, onReceive }, send }) => { log(`connected to ${url}`); connection.onerror = (error) => send({ type: "ERROR", ...error }); connection.onmessage = (data) => { if (data === "❤️ HEARTBEAT") { send({ type: "HEARTBEAT" }); } else { onReceive(data); } }; }, onExit: ({ state }) => { state.log(`disconnecting from ${state.url}`); state.connection.disconnect(); }, on: { SEND: { to: "connected", reenter: false, onTransition: ({ state, event }) => { state.connection.send(event.data); }, }, ERROR: { to: "reattemptConnection", data: ({ state }) => ({ ...state, connectionAttemptNum: 0 }), }, }, }, reattemptConnection: { onEnter: ({ state: { log, url }, send }) => { log("waiting to re-attempt connection..."); const timer = setTimeout( () => send({ type: "CONNECT", url }), Math.round(Math.random() * 10_000), ); return () => clearTimeout(timer); }, on: { CONNECT: { to: "connecting", data: ({ state, event: { url } }) => ({ ...state, url, connectionAttemptNum: state.connectionAttemptNum + 1, }), onTransition: ({ state: { log, url } }) => { log(`re-attempting connection to ${url}`); }, }, }, always: { to: "connectionError", when: ({ state: { connectionAttemptNum, maxReconnectionAttempts } }) => connectionAttemptNum === maxReconnectionAttempts, data: ({ state }) => ({ ...state, errorMessage: `Max connection attempts (${state.connectionAttemptNum}) reached for url=${state.url}`, }), }, }, }, on: { HEARTBEAT: { data: ({ state }) => ({ ...state, lastHeartbeatTime: Date.now() }), }, DISCONNECT: { to: "disconnected", data: ({ state: { maxReconnectionAttempts, log, transport, onReceive }, }) => ({ maxReconnectionAttempts, log, transport, onReceive, lastHeartbeatTime: -1, }), }, },});
/* * Fictitious external "transport / connection" API */
export interface Transport { connect(url: string): Connection;}
export interface Connection { onconnect?: (connectionId: string) => void; onmessage?: (data: string) => void; onerror?: (error: ConnectionError) => void; send(data: string): void; disconnect(): void;}
export interface ConnectionError { readonly code: number; readonly errorMessage: string;}
Usage
import type { Transport } from "./Transport";import { connectionMachine } from "./connectionMachine";
const transport: Transport = { // @ts-expect-error - example code connect(url: string) { return { /* ... */ }; },};
const onReceive = (data: string) => { console.log("received from server: ", data);};
const connection = connectionMachine .newInstance({ initialState: { name: "disconnected", maxReconnectionAttempts: 10, log: console.log.bind(console), transport, onReceive, lastHeartbeatTime: -1, }, }) .start();
connection.subscribe(({ state }) => { if (state.name === "connected") { connection.send({ type: "SEND", data: "hello from client" }); }});
connection.send({ type: "CONNECT", url: "foo://bar/baz" });
Specific State, On Event
The simplest and most obvious types of transition are done in a specific state, because of a specific event.
In the above machine we see this for
and
and
(And elsewhere.)
These types of transitions are defined at states[stateName].on[EVENT_NAME]
in the definition config
const machine = defineMachine<State, Event>({ states: { [stateName]: { on: { [EVENT_NAME]: { to: "nextStateName", /* ... other options ... */ }, // ... }, }, // ... },});
reenter: false
In some cases we want to handle an event in a specific state, and remain in the same state, without exiting and re-entering the state.
In the above machine we see this happens for
This can be used to perform a transition side-effect and still keep any current state entry side-effect active (ie, not call their cleanup function, and allow them to continue to send
events to the machine).
Simply add reenter: false
to the transition
const machine = defineMachine<State, Event>({ states: { [stateName]: { on: { [EVENT_NAME]: { to: stateName, // remain in current state reenter: false, onTransition: ({ state, event }) => ({ /* ... */ }), // optional side-effect }, // ... }, }, // ... },});
Any State, On Event
In some cases it’s more convenient to handle an event in any state.
In the above machine we see this for
These types of transitions are defined at on[EVENT_NAME]
in the definition config
const machine = defineMachine<State, Event>({ // ... on: { [EVENT_NAME]: { to: "nextStateName", /* ... other options ... */ }, },});
Optional to
Sometimes we want to handle an event in any state and then return to the current state.
In the above machine we see this for the HEARTBEAT
event
This technique is typically used to update state-data. For example, the connection machine updates the lastHeartbeatTime
state-data property whenever it receives a HEARTBEAT
event.
const machine = defineMachine<State, Event>({ // ... on { [EVENT_NAME]: { data: ({ state, event }) => ({ ...state, /* ... */ }), }, },});
Conditional transitions: when()
Wherever you can define a transition, you can also define an array of transitions, where some or all have a when()
callback.
In the above machine we see this happens for
The when()
callback has a single parameter with fields for the current state
and the event
, and SHOULD be pure and deterministic, ie, only using state
and event
to decide which transition to take.
Conditional transitions are evaluated in their definition order:
- the machine takes the first transition where
when()
returnstrue
, or there is nowhen()
- no transition is taken if all transition
when()
s returnfalse
, and there is no “default” transition
const machine = defineMachine<State, Event>({ states: { [stateName]: { on: { [EVENT_NAME]: [ { to: 'firstState', when: ({ state, event }) => { /* ... */ }, /* ... other options */ }, { to: 'firstState', when: ({ state, event }) => { /* ... */ }, /* ... other options */ }, { to: 'secondState', when: ({ state, event }) => { /* ... */ }, /* ... other options */ }, { to: 'thirdState', /* "default" transition ... other options */ }, ] } } // ... }, on { [OTHER_EVENT_NAME]: [ // array of potential transitions ], },});
Immediate transitions: always
Immediate transitions (often combined with conditional transitions) are taken immediately, on entering a state.
In the above machine we see this happens for
Immediate transitions belong to a state node in the definition config at states[stateName].always
const machine = defineMachine<State, Event>({ states: { [stateName]: { always: { /* a single transition */ }, // OR always: [ /* multiple potential transitions using `when()` */ ], }, }, // ...});
Immediate transitions are also sometimes called “event less” since they are not triggered due to any specific event, but instead by simply entering a state. For this reason the event
property of the when()
and data()
callback argument will be undefined
for these transitions.
Generate state data: data()
Since states can have associated data, you often need to provide a data()
callback in the transition to generate the data for the next state.
This callback MUST only ever generate a new object. NEVER mutate the current state data.
The callback receives the current state
and event
.
The callback SHOULD be pure and deterministic, and only use data from state
and event
to compute the data for the next state.
For homogenous state-data you also have the option to set enableCopyDataOnTransition: true
to avoid some boilerplate. See the states documentation for a longer discussion.
Combining transitions
We can mix-and-match most of the above, eg:
- anywhere you can define a transition, it can be a single transition or multiple potential transitions
- anywhere you can define a transition, you can use
when()
,data()
andonTransition()
- event-driven transitions from specific states MAY co-exist with immediate
always
transitions (thereattemptConnection
state in the example above does this)
The two special cases in the configuration are
to
is optional in an any state transitions: ifto
is not provided, it means exit and re-enter the current state, updating data if relevantreenter: false
in a state + event transition means perform the optional side-effect but do not exit and re-enter the current state or update data
Side-effects: onTransition()
Transitions MAY define an onTransition()
side-effect function that is called if and when the transition is taken.
In the above state machine there are transition side effects when handling
- the
SEND
event in theconnected
state - the
CONNECT
event in thereattemptConnection
state
The onTransition()
function receives a single parameter with fields for the current state
, event
, and a send
function which can be used to send events back to the machine instance. It MAY return a cleanup function to release any associated resources, and if so that is called immediately after onTransition()
is called.