Skip to content

State

State represents all the possible states in which a machine can exist.

States are finite

This means that we know all the states when the machine is defined.

In this “connection” state-machine, the states are disconnected, connecting, connected and connectionError.

The machine will only ever be in one of these states; new states cannot be created dynamically (at run-time).

State is a type

In yay-machine the machine’s state is a TypeScript type with a name: string property, and any other associated data.

So we might start to build the above machine like

connectionMachine.ts
import { defineMachine } from "yay-machine";
interface ConnectionState {
readonly name:
| "disconnected"
| "connecting"
| "connected"
| "connectionError";
}
interface ConnectionEvent {
readonly type: "???"; // we'll cover this next
}
export const connectionMachine = defineMachine<
ConnectionState,
ConnectionEvent
>({
initialState: { name: "disconnected" },
// ...
});

Get a machine’s current state

When we have an instance of a machine we can query its current state

const connection = connectionMachine.newInstance().start();
assert(connection.state).deepStrictEqual({ name: "disconnected" });

machine.state is a property getter that always returns the machine’s current state, and whose type is the machine’s state-type, in our case ConnectionState.

Subscribing to a machine’s state

We can subscribe to a machine’s state, to be notified about state changes as they happen

const connection = connectionMachine.newInstance().start();
// the type of `state` is `ConnectionState` - our state type
const unsubscribe = connection.subscribe(({ state }) => {
switch (state.name) {
'disconnected':
console.log('we are disconnected 🤷');
break;
'connecting':
console.log('connecting now... 👋');
break;
'connected':
console.log('yay, connected 🤝');
break;
'connectionError':
console.log('connection failed 😢');
break;
}
});
// ... later
unsubscribe(); // callback no longer receives state changes

Homogenous state data

As well as a name, state types can have additional data properties

interface ConnectionState {
readonly name:
| "disconnected"
| "connecting"
| "connected"
| "connectionError";
readonly connectingStartedAt: number; // Date.now();
readonly connectionEstablishedAt: number; // Date.now();
}

In this case we say the state-data is homogenous, because for all states - disconnected, connecting, connected and connectionError - the associated state-data has the same shape (type).

The machine manages the data as it runs, by providing a data() callback to generate data for the next state

const connectionMachine = defineMachine<ConnectionState, ConnectionEvent>({
initialState: {
name: "disconnected",
connectingStartedAt: -1,
connectionEstablishedAt: -1,
},
states: {
disconnected: {
on: {
CONNECT: {
to: "connecting",
data: () => ({
connectingStartedAt: Date.now(),
connectionEstablishedAt: -1,
}),
},
},
},
connecting: {
on: {
CONNECTED: {
to: "connected",
data: ({ state }) => ({
connectingStartedAt: state.connectingStartedAt,
connectionEstablishedAt: -1,
}),
},
},
},
// ...
},
});

Later we could query the data

const connection = connectionMachine.newInstance().start();
// ... use the machine ...
if (connection.state.name === "connected") {
console.log(
"It took %s milliseconds to establish the connection, and its uptime is %s millis",
connection.state.connectionEstablishedAt -
connection.state.connectingStartedAt,
Date.now() - connection.state.connectionEstablishedAt,
);
}

We can also define conditional transitions that query both state-data and event-payloads to decide which transition to take.

enableCopyDataOnTransition

For machines with homogenous state-data and a lot of transitions, you might find you are writing a lot of boilerplate data() callbacks that simply copy the state, eg

interface ToggleState {
readonly name: "off" | "on";
readonly onTimes: number;
}
interface ToggleEvent {
readonly type: "TOGGLE";
}
const toggleMachine = defineMachine<ToggleState, ToggleEvent>({
initialState: { name: "off", onTimes: 0 },
states: {
off: {
on: {
TOGGLE: {
to: "on",
data: ({ state }) => ({ onTimes: state.onTimes + 1 }), // update state-data
},
},
},
on: {
on: {
TOGGLE: {
to: "off",
data: ({ state }) => state, // :-( this isn't adding any value
},
},
},
},
});

If your machine has a lot of transitions that don’t actually change the state-data, this means a lot of extra boilerplate and a lot of noise.

In this case you can do

const toggleMachine = defineMachine<ToggleState, ToggleEvent>({
enableCopyDataOnTransition: true, // add this
initialState: { name: "off", onTimes: 0 },
states: {
off: {
on: {
TOGGLE: {
to: "on",
data: ({ state }) => ({ onTimes: state.onTimes + 1 }), // update state-data
},
},
},
on: {
on: {
TOGGLE: { to: "off" }, // :-) state is now copied from `off` to `on`
},
},
},
});

Heterogenous state data

If we like, we can define state types with different data

type ConnectionState =
| { readonly name: 'disconnected' }
| {
readonly name: 'connecting';
readonly connectingStartedAt: number; /* Date.now(); */
}
| {
readonly name: 'connected';
readonly connectingStartedAt: number; /* Date.now(); */;
readonly connectionEstablishedAt: number; /* Date.now() */
}
| {
readonly name: 'connectionError';
readonly errorMessage: string
};

In this case we say the state-data is heterogenous, because for some or all states the associated state-data has a different shape (type).

The machine manages the data as it runs, by providing a data() callback to generate data for the next state

const connectionMachine = defineMachine<ConnectionState, ConnectionEvent>({
initialState: { name: "disconnected" },
states: {
disconnected: {
on: {
CONNECT: {
to: "connecting",
data: () => ({
connectingStartedAt: Date.now(),
}),
},
},
},
connecting: {
on: {
CONNECTED: {
to: "connected",
data: ({ state }) => ({
connectingStartedAt: state.connectingStartedAt,
connectionEstablishedAt: -1,
}),
},
ERROR: {
to: "connectionError",
data: ({ event }) => ({
errorMessage: String(event.error),
}),
},
},
},
// ...
},
});

Later we can query the data with complete type-safety

const connection = connectionMachine.newInstance().start();
// ... use the machine ...
if (connection.state.name === "connected") {
console.log(
"It took %s milliseconds to establish the connection, and its uptime is %s millis",
connection.state.connectionEstablishedAt -
connection.state.connectingStartedAt,
Date.now() - connection.state.connectionEstablishedAt,
);
} else if (connection.state.name === "connectionError") {
console.log("Connection failed: %s", connection.state.errorMessage);
}

State data is immutable

State lifecycle side-effects

States may define two optional side-effect callbacks that are executed when the state is entered and exited respectively

const connectionMachine = defineMachine<ConnectionState, ConnectionEvent>({
initialState: { name: "disconnected" },
states: {
// ...
connected: {
onEnter: ({ state }) => {
console.log("now connected to %s", state.url);
},
onExit: ({ state }) => {
console.log("no longer connected to %s", state.url);
},
},
// ...
},
});