Movatterモバイル変換


[0]ホーム

URL:


Skip to content

Navigation Menu

Search code, repositories, users, issues, pull requests...

Provide feedback

We read every piece of feedback, and take your input very seriously.

Saved searches

Use saved searches to filter your results more quickly

Sign up

Type-safe JavaScript statecharts library

License

NotificationsYou must be signed in to change notification settings

kossnocorp/superstate

Repository files navigation

Superstate logo

Superstate

Type-safe JavaScript statecharts library

🔒 End-to-end type-safe 🎯 Easy to read without visualization
🧩 Highly composable ⚡ Lightweight (1.6kB) and fast


Take a look:

import{superstate}from"superstate";typePlayerState="stopped"|"playing"|"paused";constplayerState=superstate<PlayerState>("player").state("stopped","play() -> playing").state("playing",["pause() -> paused","stop() -> stopped"],($)=>$.sub("volume",volumeState)).state("paused",["play() -> playing","stop() -> stopped"]);typeVolumeState="low"|"medium"|"high";constvolumeState=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 leaderXState. Why bother?

Superstate wasborn 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 formalismintroduced 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 havesubstates.
  • Event: What triggerstransitions between the systemstates (e.g.up() orplay()). You sendevents to control the system.
  • Transition: The process of moving from onestate to another. It's coupled with the triggeringevent and the nextstate (e.g.up() -> medium).
  • Action: What happens duringtransitions, upon entering or exiting astate (e.g.playMusic!).Actions call your code.
  • Context: Data associated with a state. It is passed withevents and avaliable on correspondingstates.

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, andactions have!. The flow of the system is described by->.

Basics

Thesuperstate function creates a new statechart. It accepts the name and available states as the generic type and returns the builder object:

typeVolumeState="low"|"medium"|"high";constvolumeState=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. Thestate method accepts the name and list of state traits—in this case—transitions.

The events that trigger state transitions areup() anddown(). Events always have() at the end, which makes them easy to spot.


To use the machine, run thehost method:

constvolume=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 theon 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");}});

Theon method returnsoff function that unsubscribes the listener:

constoff=volume.on("low",()=>{});setTimeout(()=>{// Unsubscribe the listener:off();},1000);

Guards

Transitions can be guarded, allowing to have conditional transitions:

typePCState="on"|"sleep"|"off";constpcState=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 theif method to guard the transitions. Thepress 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 thesend object:

constpc=pcState.host();// Send the long press event:constnextState=pc.send.press("long");// The next state is "off":if(nextState)nextState.namesatisfies"off";

Unless it's not in theoff state, thepress event will transition the statechart to theoff state.

If you send thepress() event without the condition, it might transition to thesleep or theon state:

// Send the press event:constnextState=pc.send.press();// The next state is "sleep" or "on":if(nextState)nextState.namesatisfies"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:

typeButtonState="off"|"on";constbuttonState=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! andturnOff!. 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 thehost method:

// Bind the actions to code:constbutton=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:constbuttonState=superstate<ButtonState>("button").state("off","press() -> on").state("on",["-> turnOn!","press() -> off","turnOff! ->"]);constbutton=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:constbuttonState=superstate<ButtonState>("button").state("off","press() -> turnOn! -> on").state("on","press() -> turnOff! -> off");constbutton=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:constbuttonState=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:

typePlayerState="stopped"|"playing"|"paused";constplayerState=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"]);typeVolumeState="low"|"medium"|"high";constvolumeState=superstate<VolumeState>("volume").state("low","up() -> medium").state("medium",["up() -> high","down() -> low"]).state("high","down() -> medium");

In this example, we nest thevolumeState inside theplaying state. ThevolumeState will be initialized when theplaying state is entered and will be destroyed when theplaying state is exited.

You can send events, subscribe to updates, and access the substate from the parent state:

constplayer=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`:constplayingState=player.in("playing");if(playingState){// Access the substate:playingState.sub.volume.in("high");}

A state can be final, representing the end of a statechart:

typeOSState="running"|"sleeping"|"terminated";constosState=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:

typePCState="on"|"off";constpcState=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:

typeOSState="running"|"sleeping"|"terminated";constosState=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!");typePCState="on"|"off";constpcState=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 hassleep! andwake! actions, so when we host the PC statechart, we must bind theOS actions as well:

constpc=pcState.host({on:{// Here we bind the substate's actionsos:{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 theState type that you can import from the library:

// Import the `State` type:import{State,superstate}from"superstate";// Specify the context types:interfaceFields{email:string;password:string;}interfaceErrorFields{error:string;}// Define the statestypeFormState=// 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:constformState=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:constform=formState.host({context:{email:"",password:"",},});// Send submit event:form.send.submit("-> complete",{email:"koss@nocorp.me",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.errorsatisfiesstring;// 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.contextsatisfiesFields&ErrorFields;}else{// Access the context in the state:if(update.state.name==="errored")update.state.contextsatisfiesFields&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 ofTypeScript'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 fromerrored state where theerror property is present, the updater function will trigger a type error when you try to pass the previouscontext 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 ofcontext isFields | (Fields & ErrorFields), while thecomplete state expectsFields. You can see from the error that theerror 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:

interfaceErrorFields{error:string;}// Accept form fields generic:functioncreateFormState<FormFields>(){typeFormState=|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:

interfaceCredentialsFields{email:string;password:string;}interfaceProfileFields{fullName:string;company:string;}// Define the states with the context types:typeSignUpState=|"credentials"|State<"profile",CredentialsFields>|State<"done",CredentialsFields&ProfileFields>;// Create the credentials form statechart:constcredentialsState=createFormState<CredentialsFields>();// Create the profile form statechart:constprofileState=createFormState<ProfileFields>();// Define the signup statechart:constsignUpState=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 theform.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:constsignUp=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 out the first form and submit it:

// Fill in the email field:signUp.send.credentials.form.update("-> pending",($,{ password})=>$({email:"koss@nocorp.me", 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, theform.complete state is bound to theprofile state, so now we should transition toprofile.

constprofile=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 merging the given final state context with the parent state context produces the exact context of the target state. If it doesn't, you'll see a type error when trying to bind incompatible states.

Likewise, when submitting the profile form, thedone state will have both credentials and profile fields:

// Submit the profile form:signUp.send.profile.form.submit("-> complete",($,{ fullName, company})=>$({ fullName, company}));constdone=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 thesuperstate function that initiates a statechart creation. It returnsthe builder object.

Once initiated, the API has three modes of operation:

superstate

The function that initiated a new statechart creation.

import{superstate}from"superstate";// Define available states:typeSwitchState="off"|"on";// Initiate the "name" statechart creation:constbuilder=superstate<SwitchState>("name");

It accepts thename string as an argument and the generic state type. Thename is used for visualization and debugging purposes, i.e., to render Mermaid diagrams. The generic type defines the available states.

It returnsthe builder object that allows you to define each state.

Builder

Thesuperstate method returns a builder object that allows you to define each state one-by-one. The builder object has the following methods:

  • state - defines the state properties.
  • final - same as thestate method but marks the state as final.

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.

conststate=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 astring orstring[].

conststate=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:

NameDefinitionDescription
Enter action-> actionName!The action that is called when the state is entered.
Exit actionactionName! ->The action that is called when the state is exited.
TransitioneventName() -> nextStateThe event that triggers the transition to the next state.
Guarded transitioneventName(condition) -> nextStateThe transition is triggered when the event is sent with the given condition.
Transition with actioneventName() -> actionName! -> nextStateThe event that triggers the transition to the next state and calls the action.
Guarded transition with actioneventName(condition) -> actionName! -> nextStateThe 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.

Read more about guards

Read more about actions

builder.state(_, [defs], builder)

Aftername ordefs, you can pass a function that accepts the state builder object ($).

// Define the state properties using the state builder object:conststate=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:conststate=superstate<SwitchState>("switch").state("off","-> turnOffLights!",($)=>$.exit("turnOnLights!").on("turnOn() -> on")).state("on",($)=>$.on("turnOff() -> onOff! -> off"));

You can usedef andbuilder interchangeably expect when defining the substates. In that case, you must use the builder function:

typePlayerState="stopped"|"playing"|"paused";constplayerState=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"]);typeVolumeState="low"|"medium"|"high";constvolumeState=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.

conststate=superstate<SwitchState>("name").state("off",($)=>$.on("turnOn() -> on")).state("on",($)=>$.on("turnOff() -> off"));

It accepts astring orstring[] as the argument:

constpcState=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:

NameDefinitionDescription
TransitioneventName() -> nextStateThe event that triggers the transition to the next state.
Guarded transitioneventName(condition) -> nextStateThe transition is triggered when the event is sent with the given condition.
Transition with actioneventName() -> actionName! -> nextStateThe event that triggers the transition to the next state and calls the action.
Guarded transition with actioneventName(condition) -> actionName! -> nextStateThe 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.

constpcState=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 theon method, except that the event name is omitted ((long) -> off instead of the completepress(long) -> off).

There can be a single transition as well as they can be mixed with theon method and even thestatedefs argument:

constpcState=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:

NameDefinitionDescription
Guarded transition(condition) -> nextStateThe transition is triggered when the event is sent with the given condition.
Guarded transition with action(condition) -> actionName! -> nextStateThe transition is triggered when the event is sent with the given condition and calls the action.
Else transition() -> nextStateThe transition is triggered when the event is sent without the condition.
Else transition with action() -> actionName! -> nextStateThe transition is triggered when the event is sent without the condition and calls the action.

Read more about guards

$.enter

The method defines an enter state action. The action is called when the state is entered.

conststate=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.

Read more about actions

$.exit

The method defines an exit state action. The action is called when the state is exited.

conststate=superstate<SwitchState>("name").state("off",($)=>$.exit("turnOnLights!").on("turnOn() -> on")).state("on",($)=>$.exit("turnOffLights!").on("turnOff() -> off"));
$.sub

The methods defines a substate.

typePlayerState="stopped"|"playing"|"paused";constplayerState=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"]);typeVolumeState="low"|"medium"|"high";constvolumeState=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:

constplaying=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:consthigh=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:

typeOSState="running"|"sleeping"|"terminated";constosState=superstate<OSState>("running").state("running","terminate() -> terminated").state("sleeping",["wake() -> running","terminate() -> terminated"])// Mark the terminated state as final.final("terminated");typePCState="on"|"off";constpcState=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 substateos.terminated (prefixed with the substate nameos), the eventshutdown(), and the parent stateoff.

After the substate OS enters the finalterminated state, the parent PC will receiveshutdown() and event and automatically transition to theoff state.

The final transitions can be astring orstring[], allowing you to connect multiple final states to the parent state.

There are no limits on the number of substates you can define.

Read more about substates

builder.final

The method works like thestate method but marks the state as final.

Seestate docs for more info.

Factory

Once all the states are defined, the type system will transitionthe builder object into the statechart factory.

A factory is a statechart definition that allows creating instances usingthehost method. It can be passed tothesub 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.

constplayer=playerState.host();

Once all the states are defined, the type system will make thehost method available. It creates an instance of the statechart.

typeButtonState="off"|"on";constbuttonState=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:

typeButtonState="off"|"on";constbuttonState=superstate<ButtonState>("button").state("off",["-> turnOff!","press() -> on"]).state("on",["-> turnOn!","press() -> off"]);constbutton=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.

typeOSState="running"|"sleeping"|"terminated";constosState=superstate<OSState>("running").state("running","terminate() -> terminateOS! -> terminated").state("sleeping",["wake() -> wakeOS! -> running","terminate() -> terminateOS! -> terminated",]).final("terminated");typePCState="on"|"off";constpcState=superstate<PCState>("pc").state("off","power() -> powerOn! -> on").state("on",($)=>$.on("power() -> powerOff! -> off").sub("os",osState));constpc=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:

constsignUp=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:

NameDefinitionDescription
Enter action-> actionName!The action that is called when the state is entered.
Exit actionactionName! ->The action that is called when the state is exited.
Transition actioneventName() -> actionName!The action that is called when the transition is triggered.
ContextcontextThe context that is passed to the initial statechart state.

factory.name

The property holds the statechart name.

typeButtonState="off"|"on";constbuttonState=superstate<ButtonState>("button").state("off","press() -> on").state("on","press() -> off");buttonState.name;//=> "button"

Instance

By calling thehost method onthe factory object, you create a statechart instance.

When creating an instance, it enters the initial state, the very first state defined inthe 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 istrue 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:constplayingState=instance.in("playing");if(playingState){playingState.name;//=> "playing"}

The first argument is the state namestring orstring[]. It returns the state object if the statechart is in the given state ornull otherwise.

// Check if the statechart is playing or paused:conststate=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:conststate=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":conststate=instance.in(["on","on.os.sleeping"]);if(state){// Will always be "on":state.name;//=> "on"}

There are two types of available checks:

NameDefinitionDescription
State checkstateCheck if the statechart is in the given state.
Substate state checkstate.substate.substateStateCheck 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.typesatisfies"state";update.state.namesatisfies"paused";});// Trigger when the "pause()" event is sent:instance.on("pause()",(update)=>{console.log("The player is paused");update.typesatisfies"event";update.transition.eventsatisfies"pause";});

The first argument is astring orstring[] of state and event names. The second is the listener that accepts theupdate object containing the state or event information.

The method returns theoff function that unsubscribes the listener:

constoff=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.namesatisfies"paused";}else{update.transition.eventsatisfies"pause";}});

You can also subscribe to all statechart updates using the wildcard string* (note that itwon't subscribe to the substate updates, use** for that):

// Subscribe to all statechart updates:instance.on("*",(update)=>{if(update.type==="state"){update.state.namesatisfies"stopped"|"playing"|"paused";}else{update.transition.eventsatisfies"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.namesatisfies"low";}else{update.transition.eventsatisfies"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.namesatisfies"low"|"medium"|"high";}else{update.transition.eventsatisfies"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.namesatisfies|"stopped"|"playing"|"paused"|"low"|"medium"|"high";}else{update.transition.eventsatisfies"play"|"pause"|"stop"|"up"|"down";}});

There are seven types of available update targets:

NameDefinitionDescription
Statechart updates*Subscribe to all the statechart updates.
State updatestateTriggered when the statechart transitions into the state.
Event updateevent()Triggered when the event is sent to the statechart.
Substate state updatestate.substate.substateStateTriggered when the statechart transitions into the substate state.
Substate event updatestate.substate.substateEvent()Triggered when the event is sent to the substate.
All substate updatesstate.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:

constinstance=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 ornull otherwise:

constnextState=instance.send.play();// If the event triggered a transition, send will return the playing state:if(nextState){nextState.namesatisfies"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:"koss@nocorp.me",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";typeVolumeState="low"|"medium"|"high";constvolumeState=superstate<VolumeState>("volume").state("low","up() -> medium").state("medium",["up() -> high","down() -> low"]).state("high","down() -> medium");typePlayerState="stopped"|"playing"|"paused";constplayerState=superstate<PlayerState>("player").state("stopped","play() -> playing").state("playing",["pause() -> paused","stop() -> stopped"],($)=>$.sub("volume",volumeState)).state("paused",["play() -> playing","stop() -> stopped"]);constmermaid=toMermaid(playerState);

Here's themermaid result:

%% Generated with SuperstatestateDiagram-v2state "player" as player {[*] --> player.stoppedplayer.stopped --> player.playing : playplayer.playing --> player.paused : pauseplayer.playing --> player.stopped : stopplayer.paused --> player.playing : playplayer.paused --> player.stopped : stopstate "stopped" as player.stoppedstate "playing" as player.playing {[*] --> player.playing.lowplayer.playing.low --> player.playing.medium : upplayer.playing.medium --> player.playing.high : upplayer.playing.medium --> player.playing.low : downplayer.playing.high --> player.playing.medium : downstate "low" as player.playing.lowstate "medium" as player.playing.mediumstate "high" as player.playing.high}state "paused" as player.paused}

Which can be rendered as a diagramusing the Mermaid library:

%% Generated with SuperstatestateDiagram-v2state "player" as player {[*] --> player.stoppedplayer.stopped --> player.playing : playplayer.playing --> player.paused : pauseplayer.playing --> player.stopped : stopplayer.paused --> player.playing : playplayer.paused --> player.stopped : stopstate "stopped" as player.stoppedstate "playing" as player.playing {[*] --> player.playing.lowplayer.playing.low --> player.playing.medium : upplayer.playing.medium --> player.playing.high : upplayer.playing.medium --> player.playing.low : downplayer.playing.high --> player.playing.medium : downstate "low" as player.playing.lowstate "medium" as player.playing.mediumstate "high" as player.playing.high}state "paused" as player.paused}
Loading

Acknowledgments

Special thanks toEric Vicenti for donating the npm package namesuperstate to this project.

The project wouldn't exist without theXState library, a great source of inspiration and knowledge.

Changelog

Seethe changelog.

License

MIT © Sasha Koss

About

Type-safe JavaScript statecharts library

Topics

Resources

License

Stars

Watchers

Forks

Releases

No releases published

[8]ページ先頭

©2009-2025 Movatter.jp