superstate
v1.0.0-beta.4
Published
<div align="center"> <img alt="Superstate logo" height="256" src="./logo.svg" />
Downloads
12
Readme
Take a look:
import { superstate } from "superstate";
type PlayerState = "stopped" | "playing" | "paused";
const playerState = superstate<PlayerState>("player")
.state("stopped", "play() -> playing")
.state("playing", ["pause() -> paused", "stop() -> stopped"], ($) =>
$.sub("volume", volumeState)
)
.state("paused", ["play() -> playing", "stop() -> stopped"]);
type VolumeState = "low" | "medium" | "high";
const volumeState = superstate<VolumeState>("volume")
.state("low", "up() -> medium")
.state("medium", ["up() -> high", "down() -> low"])
.state("high", "down() -> medium");
Even without rendering a chart, it is easy to see the logic.
Why?
There are many state machine and statechart libraries, including the industry leader XState. Why bother?
Superstate was born out of my frustration with TypeScript. It turned out that typing a graph-based API was an extremely tough challenge, which I bravely accepted.
As statecharts play a central role in any system, set to untangle what is tangled, having complete type-safety is crucial for the task. A typo or unintended usage might ultimately break the app, so the type system must always warn you about the problem.
One reason typing such an API is problematic is the inherent composability of statecharts. This contributes to another problem — readability. That was another reason why I wanted to try my hand at it.
So, when I managed to design an API that is completely type-safe, easy to grasp without visualization, and composable, I thought it would be a crime not to give it a chance and ship it as a library.
So here we go.
Getting started
Installation
Start by installing the package:
npm i superstate
Core concepts
Superstate is an implementation of the statecharts formalism introduced by David Harel in 1987. It adds hierarchy to state machines, making it possible to express complex logic without losing readability.
To get started, you only need to understand a few concepts:
- State: The available system states. Only a single state can be active at a time (e.g.
stopped
orplaying
). A state might have substates. - Event: What triggers transitions between the system states (e.g.
up()
orplay()
). You send events to control the system. - Transition: The process of moving from one state to another. It's coupled with the triggering event and the next state (e.g.
up() -> medium
). - Action: What happens during transitions, upon entering or exiting a state (e.g.
playMusic!
). Actions call your code. - Context: Data associated with a state. It is passed with events and avaliable on corresponding states.
Everything else is built on top of these concepts.
All the concepts have consistent naming, enabling you to quickly distinguish them. For instance, events have ()
at the end, and actions have !
. The flow of the system is described by ->
.
Basics
The superstate
function creates a new statechart. It accepts the name and available states as the generic type and returns the builder object:
type VolumeState = "low" | "medium" | "high";
const volumeState = superstate<VolumeState>("volume")
.state("low", "up() -> medium")
.state("medium", ["up() -> high", "down() -> low"])
.state("high", "down() -> medium");
The first state, low
, is the initial state that the statechart will enter when it starts. The state
method accepts the name and list of state traits—in this case—transitions.
The events that trigger state transitions are up()
and down()
. Events always have ()
at the end, which makes them easy to spot.
To use the machine, run the host
method:
const volume = volumeState.host();
// Subscribe to the state updates:
volume.on(["low", "medium", "high"], (target) =>
sound.setVolume(target.state.name)
);
// Trigger the events:
volume.send.up();
// Check the current state:
if (volume.in("high")) console.log("The volume is at maximum");
The method creates an instance of statechart. It's the object that you will interact with, which holds the actual state.
Using the on
method, you can listen to everything (*
), a single state or an event, or a combination of them:
// Listen to everything:
volume.on("*", (target) => {
if (target.type === "state") {
console.log("State changed to", target.state.name);
} else {
console.log("Event triggered", target.transition.event);
}
});
// Will trigger when the state is `low` or when `down()` is sent:
volume.on(["low", "down()"], (target) => {
if (target.type === "state") {
console.log("The volume is low");
} else {
console.log("The volume is going down");
}
});
The on
method returns off
function that unsubscribes the listener:
const off = volume.on("low", () => {});
setTimeout(() => {
// Unsubscribe the listener:
off();
}, 1000);
Guards
Transitions can be guarded, allowing to have conditional transitions:
type PCState = "on" | "sleep" | "off";
const pcState = superstate<PCState>("pc")
.state("off", "press() -> on")
.state("on", ($) =>
$.if("press", ["(long) -> off", "() -> sleep"]).on("restart() -> on")
)
.state("sleep", ($) =>
$.if("press", ["(long) -> off", "() -> on"]).on("restart() -> on")
);
In this example, we used the if
method to guard the transitions. The press
event might trigger one of the two transitions: a long press and another for a short press (else).
There are several ways to define state traits, and passing a function as the last argument is one of them. It allows for defining more complex logic.
To send an event with a condition, use the send
object:
const pc = pcState.host();
// Send the long press event:
const nextState = pc.send.press("long");
// The next state is "off":
if (nextState) nextState.name satisfies "off";
Unless it's not in the off
state, the press
event will transition the statechart to the off
state.
If you send the press()
event without the condition, it might transition to the sleep
or the on
state:
// Send the press event:
const nextState = pc.send.press();
// The next state is "sleep" or "on":
if (nextState) nextState.name satisfies "sleep" | "on";
Actions
Actions allow you to define side effects ran when entering or exiting a state or during a transition.
While you trigger the events, the actions trigger your code:
type ButtonState = "off" | "on";
const buttonState = superstate<ButtonState>("button")
.state("off", ["-> turnOff!", "press() -> on"])
.state("on", ["-> turnOn!", "press() -> off"]);
You can notice that the state definitions include strings with !
at the end, i.e., turnOn!
and turnOff!
. These are the actions.
They define what happens when the state is entered and force you to handle the side effects in your code when calling the host
method:
// Bind the actions to code:
const button = buttonState.host({
on: {
"-> turnOn!": () => console.log("Turning on"),
},
off: {
"-> turnOff!": () => console.log("Turning on"),
},
});
In addition to enter actions (-> turnOff!
), states can have exit actions (turnOff! ->
), which are invoked right before the state is left:
// The on state invokes the enter and exit actions:
const buttonState = superstate<ButtonState>("button")
.state("off", "press() -> on")
.state("on", ["-> turnOn!", "press() -> off", "turnOff! ->"]);
const button = buttonState.host({
on: {
"-> turnOn!": () => console.log("Turning on"),
"turnOff! ->": () => console.log("Turning off"),
},
});
The transition actions (press() -> turnOff! -> off
) are invoked during transitions, before calling the state's exit action (if any):
// Actions are invoked on transitions:
const buttonState = superstate<ButtonState>("button")
.state("off", "press() -> turnOn! -> on")
.state("on", "press() -> turnOff! -> off");
const button = buttonState.host({
on: {
"press() -> turnOff!": () => console.log("Turning on"),
},
off: {
"press() -> turnOn!": () => console.log("Turning off"),
},
});
Like with most Superstate API, there are several ways to define actions, allowing you to choose the right one for the situation.
The events and actions can be defined in the builder function or even mixed with the string-based definitions:
// Use the builder function to define the states:
const buttonState = superstate<ButtonState>("button")
.state("on", ($) => $.enter("turnOn!").on("press() -> off").exit("turnOff!"))
.state("off", ($) => $.on("press() -> on"));
Substates
Substates are states that are nested within a parent state. A state might have multiple substates, making it a parallel state, representing concurrent logic:
type PlayerState = "stopped" | "playing" | "paused";
const playerState = superstate<PlayerState>("player")
.state("stopped", "play() -> playing")
.state("playing", ["pause() -> paused", "stop() -> stopped"], ($) =>
// Nest the volume state as `volume`
$.sub("volume", volumeState)
)
.state("paused", ["play() -> playing", "stop() -> stopped"]);
type VolumeState = "low" | "medium" | "high";
const volumeState = superstate<VolumeState>("volume")
.state("low", "up() -> medium")
.state("medium", ["up() -> high", "down() -> low"])
.state("high", "down() -> medium");
In this example, we nest the volumeState
inside the playing
state. The volumeState
will be initialized when the playing
state is entered and will be destroyed when the playing
state is exited.
You can send events, subscribe to updates, and access the substate from the parent state:
const player = playerState.host();
// Send events to the substate:
player.send.playing.volume.up();
// Subscribe to the substate state updates:
player.on("playing.volume.low", (target) => console.log("The volume is low"));
// The parent state will have the substate as a property on `sub`:
const playingState = player.in("playing");
if (playingState) {
// Access the substate:
playingState.sub.volume.in("high");
}
A state can be final, representing the end of a statechart:
type OSState = "running" | "sleeping" | "terminated";
const osState = superstate<OSState>("running")
.state("running", "terminate() -> terminated")
.state("sleeping", ["wake() -> running", "terminate() -> terminated"])
// Mark the terminated state as final
.final("terminated");
When nesting such a state, the parent might connect the substate's final states through an event to a parent state, allowing for a more complex logic:
type PCState = "on" | "off";
const pcState = superstate<PCState>("pc")
.state("off", "power() -> on")
.state("on", ($) =>
$.on("power() -> off")
// Nest the OS state as `os` and connect the `terminated` state
// through `shutdown()` event to `off` state of the parent.
.sub("os", osState, "os.terminated -> shutdown() -> off")
);
When the OS is terminated, the PC will automatically power off.
If a substate has actions, they must be bound when hosting the root statechart.
Look at this fairly complex statechart:
type OSState = "running" | "sleeping" | "terminated";
const osState = superstate<OSState>("running")
.state("running", [
"terminate() -> terminated",
// Note sleep! action
"sleep() -> sleep! -> sleeping",
])
.state("sleeping", [
// Note wake! action
"wake() -> wake! -> running",
"terminate() -> terminated",
])
.final("terminated", "-> terminate!");
type PCState = "on" | "off";
const pcState = superstate<PCState>("pc")
.state("off", "power() -> turnOn! -> on")
.state("on", ($) =>
// Here we add OS state as a substate
$.on("power() -> turnOff! -> off").sub(
"os",
osState,
"os.terminated -> shutdown() -> off"
)
);
The PC (personal computer) statechart nests OS (operating system). The OS has sleep!
and wake!
actions, so when we host the PC statechart, we must bind the OS
actions as well:
const pc = pcState.host({
on: {
// Here we bind the substate's actions
os: {
running: {
"sleep() -> sleep!": () => console.log("Sleeping"),
},
sleeping: {
"wake() -> wake!": () => console.log("Waking up"),
},
terminated: {
"-> terminate!": () => console.log("Terminating"),
},
},
"power() -> turnOff!": () => console.log("Turning off"),
},
off: {
"power() -> turnOn!": () => console.log("Turning on"),
},
});
Contexts
Superstate allows pairing states with a data structure called context. A state with assigned context will require you to pass the specified data structure when sending events or hosting the statechart.
To define states with context, use the State
type that you can import from the library:
// Import the `State` type:
import { State, superstate } from "superstate";
// Specify the context types:
interface Fields {
email: string;
password: string;
}
interface ErrorFields {
error: string;
}
// Define the states
type FormState =
// Pass the context as the second generic parameter:
| State<"pending", Fields>
| State<"errored", Fields & ErrorFields>
| State<"complete", Fields>
// You can also mix with strings:
| "canceled";
// Define the form statechart:
const formState = superstate<FormState>("form")
.state("pending", [
"submit(error) -> errored",
"submit() -> complete",
"cancel() -> canceled",
])
.state("errored", [
"submit(error) -> errored",
"submit() -> complete",
"cancel() -> canceled",
])
.final("complete")
.final("canceled");
When creating an instance or sending events, you must pass the context data:
// Pass the initial context:
const form = formState.host({
context: {
email: "",
password: "",
},
});
// Send submit event:
form.send.submit("-> complete", {
email: "[email protected]",
password: "123456",
});
Note that you must specify the destination state (-> complete
) when sending an event with context, as events with the same name can transition to different states. While it's not a problem when sending events without context, sending context to the wrong state will lead to unexpected behavior.
When sending an event with a condition, specify the condition before the destination state:
// Send submit with the error condition:
form.send.submit("error", "-> errored", {
email: "",
password: "123456",
error: "Email is missing",
});
The context will be available on the state and transition objects:
// Access context via the state:
if (form.state.name === "errored") form.state.context.error satisfies string;
// Receive the context with updates:
form.on("*", (update) => {
if (update.type === "event") {
// Access the context in the transition:
if (update.transition.to === "errored")
update.transition.context satisfies Fields & ErrorFields;
} else {
// Access the context in the state:
if (update.state.name === "errored")
update.state.context satisfies Fields & ErrorFields;
}
});
As the context is required, it will always be available on corresponding entities. Superstate guarantees context to always be of the specified type.
When sending events, you have to pass a complete context data structure. To make it easier, send
allows you to pass an updater function with the current context passed as an argument, allowing you to propagate the context from the previous state:
// Build new context using the previous state context:
form.send.submit("error", "-> errored", ($, context) =>
$({ ...context, error: "Email is missing" })
);
The updater function receives two arguments: the validation functions and the previous context. While the validation function doesn't do anything in the runtime, it guarantees context consistency at the type level. It solves the problem of TypeScript's structural typing that doesn't prevent returning extra fields that are not part of the context. Most of the time, this wouldn't be a problem when dealing with the state the extra fields might lead to unexpected behavior, so the approach with the validation function that triggers type check is a good compromise.
For instance, when transitioning from errored
state where the error
property is present, the updater function will trigger a type error when you try to pass the previous context
as is:
form.send.submit("-> complete", ($, context) => $(context));
// ~~~~~~~
//> Property 'error' is missing in type 'Fields' but required in type '{ error: never; }'
The reason is that the type of context
is Fields | (Fields & ErrorFields)
, while the complete
state expects Fields
. You can see from the error that the error
is expected to be never (no pun intended).
To fix the problem, cherry-pick the required properties:
// Cherry-pick email and password:
form.send.submit("-> complete", ($, { email, password }) =>
$({ email, password })
);
Contexts get more powerful when combined with substates. Let's describe a multistep signup form. Let's start with an abstract form statechart builder:
interface ErrorFields {
error: string;
}
// Accept form fields generic:
function createFormState<FormFields>() {
type FormState =
| State<"pending", FormFields & {}>
| State<"errored", FormFields & ErrorFields>
| State<"complete", FormFields & {}>;
return (
superstate<FormState>("form")
.state("pending", [
// update() will allow use to
"update() -> pending",
"submit(error) -> errored",
"submit() -> complete",
])
.state("errored", [
"update() -> pending",
"submit(error) -> errored",
"submit() -> complete",
])
// Mark the complete state as final:
.final("complete")
);
}
Now, let's define the main signup statechart:
interface CredentialsFields {
email: string;
password: string;
}
interface ProfileFields {
fullName: string;
company: string;
}
// Define the states with the context types:
type SignUpState =
| "credentials"
| State<"profile", CredentialsFields>
| State<"done", CredentialsFields & ProfileFields>;
// Create the credentials form statechart:
const credentialsState = createFormState<CredentialsFields>();
// Create the profile form statechart:
const profileState = createFormState<ProfileFields>();
// Define the signup statechart:
const signUpState = superstate<SignUpState>("signUp")
.state("credentials", ($) =>
$.sub("form", credentialsState, [
// When the form is complete, transition to profile:
"form.complete -> submit() -> profile",
])
)
.state("profile", ($) =>
$.sub("form", profileState, [
// When the form is complete, transition to done:
"form.complete -> submit() -> done",
])
)
.final("done");
Note that we bind the form.complete
states to the next state in the signup statechart. This way, when the form is submitted without errors, the signup statechart will transition to the next state.
Finally, let's take a look how the flow might look like.
First, we create the instance:
// Since we require the full context in each form initial state, we have
// to specify the initial context for each form:
const signUp = signUpState.host({
credentials: {
form: {
// Initial context for the credentials form:
context: {
email: "",
password: "",
},
},
},
profile: {
form: {
// Initial context for the profile form:
context: {
company: "",
fullName: "",
},
},
},
});
We could have made the initial context optional (State<"pending", Partial<FormFields> >
) and skipped specifying the initial context when hosting, but then you wouldn't learn about it, would you?
Now, let's fill the first form and submit it:
// Fill in the email field:
signUp.send.credentials.form.update("-> pending", ($, { password }) =>
$({ email: "[email protected]", password })
);
// Fill in the password field:
signUp.send.credentials.form.update("-> pending", ($, { email }) =>
$({ email, password: "123456" })
);
// Submit the form:
signUp.send.credentials.form.submit("-> complete", ($, { email, password }) =>
$({ email, password })
);
If you remember, the form.complete
state is bound to the profile
state, so now we should transition to profile
.
const profile = signUp.in("profile");
if (profile) {
// You can access email and password from the profile state:
const { email, password } = profile.context;
console.log({ email, password });
}
You might have missed it, but we never explicitly assigned the profile state! Where did it get from?!
This is where magic happens! The final substate context automatically merges with the parent context and assign it to the next state.
When binding the final state, Superstate checks if when merging the given final state context with the parent state context produces exact context of the target state. If it doesn't you'll see a type error when trying to bind such states.
Likewise, when submitting the profile form, the done
state will have both credentials and profile fields:
// Submit the profile form:
signUp.send.profile.form.submit("-> complete", ($, { fullName, company }) =>
$({ fullName, company })
);
const done = signUp.in("done");
if (done) {
// You can access all the context fields:
const { email, password, fullName, company } = done.context;
console.log({ email, password, fullName, company });
}
API
The main entry point of the Superstate API is the superstate
function that initiates a statechart creation. It returns the builder object.
Once initiated, the API has three modes of operation:
Builder - the object that allows defining state properties. Once all the states are defined, the builder turns into the factory object.
Factory - the object that creates statechart instances and holds the statechart information. It can be used as a substate.
Instance - the statechart instance created by the factory allows interacting with the statechart.
superstate
The function that initiated a new statechart creation.
import { superstate } from "superstate";
// Define available states:
type SwitchState = "off" | "on";
// Initiate the "name" statechart creation:
const builder = superstate<SwitchState>("name");
It accepts the name
string as an argument and the generic state type. The name
is used for visualization and debugging purposes, i.e., to render Mermaid diagrams. The generic type defines the available states.
It returns the builder object that allows you to define each state.
Builder
The superstate
method returns a builder object that allows you to define each state one-by-one. The builder object has the following methods:
All methods return the builder object, allowing you to chain the state definitions.
builder.state
The method defines the state properties, such as transitions, actions, and substates.
const state = superstate<SwitchState>("name")
.state("off", "turnOn() -> on")
.state("on", "turnOff() -> off");
The first state in the builder chain is the initial state.
It accepts 1-3 arguments. The first argument is the state name (name
), followed by optional property string definitions (defs
) and the optional state builder function (builder
).
builder.state(_, defs)
Pass string definitions as the second argument to define the state transitions and actions. The argument can be a string
or string[]
.
const state = superstate<SwitchState>("name")
.state("off", [
// Enter action: call `turnOffLights!` action upon entering the state
"-> turnOffLights!",
// Exit action: call `turnOnLights!` action upon exiting the state
"turnOnLights! ->",
// Transition: when `turnOn()` event is sent, transition to the on state
"turnOn() -> on",
])
// Transitions with action: call `onOff!` action when `turnOff()` event
// is sent before transitioning to the `off` state.
.state("on", "turnOff() -> onOff! -> off");
There are six types of available definitions:
| Name | Definition | Description |
| ------------------------------ | -------------------------------------------------- | ------------------------------------------------------------------------------------------------- |
| Enter action | -> actionName!
| The action that is called when the state is entered. |
| Exit action | actionName! ->
| The action that is called when the state is exited. |
| Transition | eventName() -> nextState
| The event that triggers the transition to the next state. |
| Guarded transition | eventName(condition) -> nextState
| The transition is triggered when the event is sent with the given condition. |
| Transition with action | eventName() -> actionName! -> nextState
| The event that triggers the transition to the next state and calls the action. |
| Guarded transition with action | eventName(condition) -> actionName! -> nextState
| The transition is triggered when the event is sent with the given condition and calls the action. |
There're no limit on the number of transitions and actions you can define.
builder.state(_, [defs], builder)
After name
or defs
, you can pass a function that accepts the state builder object ($
).
// Define the state properties using the state builder object:
const state = superstate<SwitchState>("switch")
.state("off", ($) =>
$.enter("turnOffLights!").exit("turnOnLights!").on("turnOn() -> on")
)
.state("on", ($) => $.on("turnOff() -> onOff! -> off"));
You can combine the string definitions with the builder function:
// Use both string and builder function definitions:
const state = superstate<SwitchState>("switch")
.state("off", "-> turnOffLights!", ($) =>
$.exit("turnOnLights!").on("turnOn() -> on")
)
.state("on", ($) => $.on("turnOff() -> onOff! -> off"));
You can use def
and builder
interchangeably expect when defining the substates. In that case, you must use the builder function:
type PlayerState = "stopped" | "playing" | "paused";
const playerState = superstate<PlayerState>("player")
.state("stopped", "play() -> playing")
.state("playing", ["pause() -> paused", "stop() -> stopped"], ($) =>
// Define the substate using the builder function:
$.sub("volume", volumeState)
)
.state("paused", ["play() -> playing", "stop() -> stopped"]);
type VolumeState = "low" | "medium" | "high";
const volumeState = superstate<VolumeState>("volume")
.state("low", "up() -> medium")
.state("medium", ["up() -> high", "down() -> low"])
.state("high", "down() -> medium");
The state builder also defines the enter and exit actions more explicitly, which some will find easier to read.
The state builder has the following methods:
$.on
- defines the state transitions.$.if
- defines the guarded transitions.$.enter
- defines the enter action.$.exit
- defines the exit action.$.sub
- defines the substate.
$.on
The method defines the state transitions.
const state = superstate<SwitchState>("name")
.state("off", ($) => $.on("turnOn() -> on"))
.state("on", ($) => $.on("turnOff() -> off"));
It accepts a string
or string[]
as the argument:
const pcState = superstate<PCState>("pc")
.state("off", "press() -> on")
.state("on", ($) =>
// Chain the transitions:
$.on("press(long) -> off").on("press() -> sleep").on("restart() -> on")
)
.state("sleep", ($) =>
// Pass all at once:
$.on(["press(long) -> off", "press() -> on", "restart() -> on"])
);
There are four types of available definitions:
| Name | Definition | Description |
| ------------------------------ | -------------------------------------------------- | ------------------------------------------------------------------------------------------------- |
| Transition | eventName() -> nextState
| The event that triggers the transition to the next state. |
| Guarded transition | eventName(condition) -> nextState
| The transition is triggered when the event is sent with the given condition. |
| Transition with action | eventName() -> actionName! -> nextState
| The event that triggers the transition to the next state and calls the action. |
| Guarded transition with action | eventName(condition) -> actionName! -> nextState
| The transition is triggered when the event is sent with the given condition and calls the action. |
$.if
The method defines a guarded transition. It accepts the event name as the first argument and transition definitions as the second argument.
const pcState = superstate<PCState>("pc")
.state("off", "press() -> on")
.state("on", ($) =>
// When `press` event with `long` condition is sent, transition to the `off` state.
// Otherwise, transition to the `sleep` state.
$.if("press", ["(long) -> off", "() -> sleep"]).on("restart() -> on")
)
.state("sleep", ($) =>
// When `press` event with `long` condition is sent, transition to the `off` state.
// Otherwise, transition to the `on` state.
$.if("press", ["(long) -> off", "() -> on"]).on("restart() -> on")
);
The transitions definition is the same as with the on
method, except that the event name is omitted ((long) -> off
instead of the complete press(long) -> off
).
There can be a single transition as well as they can be mixed with the on
method and even the state
defs
argument:
const pcState = superstate<PCState>("pc")
.state("off", "press() -> on")
// Mix with the `defs` argument:
.state("on", "press() -> sleep", ($) =>
// Single guarded transition:
$.if("press", "(long) -> off").on("restart() -> on")
)
.state("sleep", ($) =>
$.if("press", ["(long) -> off", "() -> on"]).on("restart() -> on")
);
There are four types of available guarded definitions:
| Name | Definition | Description |
| ------------------------------ | ----------------------------------------- | ------------------------------------------------------------------------------------------------- |
| Guarded transition | (condition) -> nextState
| The transition is triggered when the event is sent with the given condition. |
| Guarded transition with action | (condition) -> actionName! -> nextState
| The transition is triggered when the event is sent with the given condition and calls the action. |
| Else transition | () -> nextState
| The transition is triggered when the event is sent without the condition. |
| Else transition with action | () -> actionName! -> nextState
| The transition is triggered when the event is sent without the condition and calls the action. |
$.enter
The method defines an enter state action. The action is called when the state is entered.
const state = superstate<SwitchState>("name")
.state("off", ($) => $.enter("turnOffLights!").on("turnOn() -> on"))
.state("on", ($) => $.enter("turnOnLights!").on("turnOff() -> off"));
You can define any number of enter actions.
$.exit
The method defines an exit state action. The action is called when the state is exited.
const state = superstate<SwitchState>("name")
.state("off", ($) => $.exit("turnOnLights!").on("turnOn() -> on"))
.state("on", ($) => $.exit("turnOffLights!").on("turnOff() -> off"));
$.sub
The methods defines a substate.
type PlayerState = "stopped" | "playing" | "paused";
const playerState = superstate<PlayerState>("player")
.state("stopped", "play() -> playing")
.state("playing", ["pause() -> paused", "stop() -> stopped"], ($) =>
// Nest the volume statechart as `volume`
$.sub("volume", volumeState)
)
.state("paused", ["play() -> playing", "stop() -> stopped"]);
type VolumeState = "low" | "medium" | "high";
const volumeState = superstate<VolumeState>("volume")
.state("low", "up() -> medium")
.state("medium", ["up() -> high", "down() -> low"])
.state("high", "down() -> medium");
The first argument is the alias of the substate, that will allow you access the substate from the parent state:
const playing = player.in("playing");
// Access the volume substate:
if (playing) console.log("Is volume high? ", playing.sub.volume.in("high"));
// Or using the dot notation from the parent:
const high = player.in("playing.volume.high");
console.log("Is volume high? ", high);
The second argument is the substate factory.
If the substate has final states, you can connect them to the parent state through an event:
type OSState = "running" | "sleeping" | "terminated";
const osState = superstate<OSState>("running")
.state("running", "terminate() -> terminated")
.state("sleeping", ["wake() -> running", "terminate() -> terminated"])
// Mark the terminated state as final
.final("terminated");
type PCState = "on" | "off";
const pcState = superstate<PCState>("pc")
.state("off", "power() -> on")
.state("on", ($) =>
$.on("power() -> off")
// Nest the OS state as `os` and connect the `terminated` state
// through `shutdown()` event to `off` state of the parent.
.sub("os", osState, "os.terminated -> shutdown() -> off")
);
The transitions consist of the final substate os.terminated
(prefixed with the substate name os
), the event shutdown()
, and the parent state off
.
After the substate OS enters the final terminated
state, the parent PC will receive shutdown()
and event and automatically transition to the off
state.
The final transitions can be a string
or string[]
, allowing you to connect multiple final states to the parent state.
There are no limits on the number of substates you can define.
builder.final
The method works like the state
method but marks the state as final.
See state
docs for more info.
Factory
Once all the states are defined, the type system will transition the builder object into the statechart factory.
A factory is a statechart definition that allows creating instances using the host
method. It can be passed to the sub
method as a substate.
The factory also makes the statechart information available for debugging and visualization tools.
factory.host
The method creates a statechart instance that holds the current state and allows you to interact with it, by subscribing to state and event updates, sending events, and checking the current state, etc.
const player = playerState.host();
Once all the states are defined, the type system will make the host
method available. It creates an instance of the statechart.
type ButtonState = "off" | "on";
const buttonState = superstate<ButtonState>("button")
.state("off", "press() -> on")
.state("on", "press() -> off");
If the statechart or its substates have actions, the method argument will allow to bind those actions to the code:
type ButtonState = "off" | "on";
const buttonState = superstate<ButtonState>("button")
.state("off", ["-> turnOff!", "press() -> on"])
.state("on", ["-> turnOn!", "press() -> off"]);
const button = buttonState.host({
on: {
"-> turnOn!": () => console.log("Turning on"),
},
off: {
"-> turnOff!": () => console.log("Turning off"),
},
});
The action bindings, allows binding the enter, exit, and transition actions, including all the nested substate actions.
type OSState = "running" | "sleeping" | "terminated";
const osState = superstate<OSState>("running")
.state("running", "terminate() -> terminateOS! -> terminated")
.state("sleeping", [
"wake() -> wakeOS! -> running",
"terminate() -> terminateOS! -> terminated",
])
.final("terminated");
type PCState = "on" | "off";
const pcState = superstate<PCState>("pc")
.state("off", "power() -> powerOn! -> on")
.state("on", ($) => $.on("power() -> powerOff! -> off").sub("os", osState));
const pc = pcState.host({
on: {
// Bind the root's transition action:
"power() -> powerOff!": () => console.log("Turning off PC"),
os: {
// Bind the substate's transition actions:
running: {
"terminate() -> terminateOS!": () => console.log("Terminating OS"),
},
sleeping: {
"terminate() -> terminateOS!": () => console.log("Terminating OS"),
"wake() -> wakeOS!": () => console.log("Waking OS"),
},
},
},
off: {
"power() -> powerOn!": () => console.log("Turning on PC"),
},
});
The hierarchy of the statecharts is preserved, so you bind each state and substate actions individually.
When the statechart's or a substate's initial state has assigned context, you must pass the context when hosting the statechart:
const signUp = signUpState.host({
// The statechart's initial context:
context: {
ref: "unknown",
},
credentials: {
form: {
// Initial context for the credentials form:
context: {
email: "",
password: "",
},
},
},
profile: {
form: {
// Initial context for the profile form:
context: {
company: "",
fullName: "",
},
},
},
});
If all the context fields are optional, you can skip assigning it when hosting the statechart. Otherwise, you'll see a type error.
There are four types of available bindings:
| Name | Definition | Description |
| ----------------- | ---------------------------- | ----------------------------------------------------------- |
| Enter action | -> actionName!
| The action that is called when the state is entered. |
| Exit action | actionName! ->
| The action that is called when the state is exited. |
| Transition action | eventName() -> actionName!
| The action that is called when the transition is triggered. |
| Context | context
| The context that is passed to the initial statechart state. |
factory.name
The property holds the statechart name.
type ButtonState = "off" | "on";
const buttonState = superstate<ButtonState>("button")
.state("off", "press() -> on")
.state("on", "press() -> off");
buttonState.name;
//=> "button"
Instance
By calling the host
method on the factory object, you create a statechart instance.
When creating an instance, it enters the initial state, the very first state defined in the builder.
The instances allows to interact with the statechart, by listening to state and transition updates, sending events, checking the current state, etc.
Here are the available methods and properties:
state
- the current state of the statechart.finalized
- is the statechart in the final state?in
- checks if the statechart is in the given state.on
- subscribes to the state and transition updates.send
- proxy object that allows to send events to the statechart.off
- unsubscribes all the statechart listeners.
instance.state
The property holds the current state of the statechart.
instance.send.play();
// Check the current state:
instance.state.name;
//=> "playing"
instance.finalized
The property is true
if the statechart has reached a final state.
instance.send.terminate();
// Check if the statechart is finalized:
instance.finalized;
//=> true
instance.in
The method checks if the statechart is in the given state.
// Check if the statechart is playing:
const playingState = instance.in("playing");
if (playingState) {
playingState.name;
//=> "playing"
}
The first argument is the state name string
or string[]
. It returns the state object if the statechart is in the given state or null
otherwise.
// Check if the statechart is playing or paused:
const state = instance.in(["playing", "paused"]);
if (state) {
state.name;
//=> "playing" | "paused"
}
It also accepts the dot-notation path to nested substates.
// Check if the statechart is in the `on` state and the `os` substate
// is in the `sleeping` state:
const state = instance.in("on.os.sleeping");
if (state) {
state.name;
//=> "sleeping"
}
When a few overlapping states are passed, the method returns the first state that matches the condition.
// "on.os.sleeping" is a substate of "on":
const state = instance.in(["on", "on.os.sleeping"]);
if (state) {
// Will always be "on":
state.name;
//=> "on"
}
There are two types of available checks:
| Name | Definition | Description |
| -------------------- | ------------------------------ | ------------------------------------------------------- |
| State check | state
| Check if the statechart is in the given state. |
| Substate state check | state.substate.substateState
| Check if the statechart is in the given substate state. |
instance.on
The method subscribes to the state and event updates.
// Trigger when the instances tranisitions into the "paused" state:
instance.on("paused", (update) => {
console.log("The player is now paused");
update.type satisfies "state";
update.state.name satisfies "paused";
});
// Trigger when the "pause()" event is sent:
instance.on("pause()", (update) => {
console.log("The player is paused");
update.type satisfies "event";
update.transition.event satisfies "pause";
});
The first argument is a string
or string[]
of state and event names. The second is the listener that accepts the update
object containing the state or event information.
The method returns the off
function that unsubscribes the listener:
const off = instance.on("paused", () => {});
off();
// Won't trigger the listener:
instance.send.pause();
Subscribe to multiple state and event updates at once:
// Trigger on "pause()" event and "paused" state:
instance.on(["paused", "pause()"], (update) => {
if (update.type === "state") {
update.state.name satisfies "paused";
} else {
update.transition.event satisfies "pause";
}
});
You can also subscribe to all statechart updates using the wildcard string *
(note that it won't subscribe to the substate updates, use **
for that):
// Subscribe to all statechart updates:
instance.on("*", (update) => {
if (update.type === "state") {
update.state.name satisfies "stopped" | "playing" | "paused";
} else {
update.transition.event satisfies "play" | "pause" | "stop";
}
});
When the statechart has substates, you can subscribe to the substate updates using the dot-notation path:
// Subscribe to substate updates:
instance.on(["playing.volume.down()", "playing.volume.low"], (update) => {
if (update.type === "state") {
update.state.name satisfies "low";
} else {
update.transition.event satisfies "down";
}
});
To subscribe to all substate updates, use the wildcard string (state.substate.*
):
// Subscribe to all substate updates:
instance.on("playing.volume.*", (update) => {
if (update.type === "state") {
update.state.name satisfies "low" | "medium" | "high";
} else {
update.transition.event satisfies "up" | "down";
}
});
It's also possible to subscribe to all the updates including the root and substates using the double wildcard string (**
):
// Subscribe to all updates:
instance.on("**", (update) => {
if (update.type === "state") {
update.state.name satisfies
| "stopped"
| "playing"
| "paused"
| "low"
| "medium"
| "high";
} else {
update.transition.event satisfies "play" | "pause" | "stop" | "up" | "down";
}
});
There are seven types of available update targets:
| Name | Definition | Description |
| --------------------- | -------------------------------- | ------------------------------------------------------------------ |
| Statechart updates | *
| Subscribe to all the statechart updates. |
| State update | state
| Triggered when the statechart transitions into the state. |
| Event update | event()
| Triggered when the event is sent to the statechart. |
| Substate state update | state.substate.substateState
| Triggered when the statechart transitions into the substate state. |
| Substate event update | state.substate.substateEvent()
| Triggered when the event is sent to the substate. |
| All substate updates | state.substate.*
| Subscribe to all the substate updates. |
| All updates | **
| Subscribe to all the statechart and substate updates. |
instance.send
This proxy object allows to send events to the statechart.
instance.on("playing", () => console.log("Playing!"));
// Send "play()", trigger the listener and print "Playing!":
instance.send.play();
Pass the condition as the argument to trigger the guarded event:
const instance = pcMachine.host();
instance.on("press(long)", () => console.log("Pressed long"));
// Won't trigger the listener:
instance.send.press();
// Will trigger the listener and print "Pressed long":
instance.send.press("long");
The event methods return the next state if the event leads to a transition or null
otherwise:
const nextState = instance.send.play();
// If the event triggered a transition, send will return the playing state:
if (nextState) {
nextState.name satisfies "playing";
}
To send the event to a substate, access it by the parent state and the substate's name:
instance.on("playing.volume.up()", () => console.log("Volume up!"));
// Will trigger the listener and print "Volume up!":
instance.send.playing.volume.up();
When sending the event transitions to a state with a context, you have to pass the destination and the context as arguments:
instance.send.form.submit("-> complete", {
email: "[email protected]",
password: "123456",
});
When sending a guarded event with a context, pass the condition as the first argument:
instance.send.form.submit("error", "-> errored", {
email: "",
password: "123456",
error: "Email is missing",
});
instance.off
The method unsubscribes all statechart update listeners.
instance.on("playing", () => console.log("Playing!"));
// Unsubscribe from all the updates:
instance.off();
// Won't trigger the listener:
instance.send.play();
Mermaid
Superstate comes with Mermaid support, allowing you to visualize a statechart as a diagram.
toMermaid
The method renders the passed statechart as a Mermaid diagram code:
import { superstate } from "superstate";
import { toMermaid } from "superstate/mermaid";
type VolumeState = "low" | "medium" | "high";
const volumeState = superstate<VolumeState>("volume")
.state("low", "up() -> medium")
.state("medium", ["up() -> high", "down() -> low"])
.state("high", "down() -> medium");
type PlayerState = "stopped" | "playing" | "paused";
const playerState = superstate<PlayerState>("player")
.state("stopped", "play() -> playing")
.state("playing", ["pause() -> paused", "stop() -> stopped"], ($) =>
$.sub("volume", volumeState)
)
.state("paused", ["play() -> playing", "stop() -> stopped"]);
const mermaid = toMermaid(playerState);
Here's the mermaid
result:
%% Generated with Superstate
stateDiagram-v2
state "player" as player {
[*] --> player.stopped
player.stopped --> player.playing : play
player.playing --> player.paused : pause
player.playing --> player.stopped : stop
player.paused --> player.playing : play
player.paused --> player.stopped : stop
state "stopped" as player.stopped
state "playing" as player.playing {
[*] --> player.playing.low
player.playing.low --> player.playing.medium : up
player.playing.medium --> player.playing.high : up
player.playing.medium --> player.playing.low : down
player.playing.high --> player.playing.medium : down
state "low" as player.playing.low
state "medium" as player.playing.medium
state "high" as player.playing.high
}
state "paused" as player.paused
}
Which can be rendered as a diagram using the Mermaid library:
%% Generated with Superstate
stateDiagram-v2
state "player" as player {
[*] --> player.stopped
player.stopped --> player.playing : play
player.playing --> player.paused : pause
player.playing --> player.stopped : stop
player.paused --> player.playing : play
player.paused --> player.stopped : stop
state "stopped" as player.stopped
state "playing" as player.playing {
[*] --> player.playing.low
player.playing.low --> player.playing.medium : up
player.playing.medium --> player.playing.high : up
player.playing.medium --> player.playing.low : down
player.playing.high --> player.playing.medium : down
state "low" as player.playing.low
state "medium" as player.playing.medium
state "high" as player.playing.high
}
state "paused" as player.paused
}
Acknowledgments
Special thanks to Eric Vicenti for donating the npm package name superstate
to this project.
The project wouldn't exist without the XState library, a great source of inspiration and knowledge.
Changelog
See the changelog.