State Machines in JavaScript with XState
In this article, we will learn about State Machines in Javascript with XState.

State Machines in JavaScript with XState
In this article, we will learn about State Machines in Javascript with XState.


State machines are models that govern a finite number of states and the events that transition from one state to the other. They are abstractions that allows us to explicitly define our application's path instead of guarding against a million possible paths.
A state machine for traffic lights, for instance, would hold three states: red, yellow, and green. Green transitions to yellow, yellow to red, and red back to green. Having a state machine define our logic makes it impossible to have an illegal transition from red to yellow or yellow to green.
To see how state machines can vastly reduce the complexity of our UI, business logic, and code, we’ll take the following example:
We have a light bulb that can be either lit
, unlit
, or broken
and three buttons that can turn the bulb on or off, or break it, as represented by the following HTML code:
<>Copy<p>The light bulb is <span id="lightbulb">lit</span></p> <button id='turn-on'>turn on</button> <button id='turn-off'>turn off</button> <button id='break'>break</button>
We will reference our button elements and add click event listeners to implement our business logic.
<>Copyconst lightbulb = document.getElementById("lightbulb") const turnBulbOn = document.getElementById("turn-on") const turnBulbOff = document.getElementById("turn-off") const breakBulb = document.getElementById("break") turnBulbOn.addEventListener("click", () => { lightbulb.innerText = "lit" }) turnBulbOff.addEventListener("click", () => { lightbulb.innerText = "unlit" }) breakBulb.addEventListener("click", () => { lightbulb.innerText = "broken" })
If the light bulb is broken, turning it on again should be an impossible state transition. But the implementation above allows us to do that by simply clicking the button. So we need to guard against the transition from the broken state to the lit state. To do that, we often resort to boolean flags or verbose checks before each action, as such:
<>Copylet isBroken = false turnBulbOn.addEventListener("click", () => { if (!isBroken) { lightbulb.innerText = "lit" } }) turnBulbOff.addEventListener("click", () => { if (!isBroken) { lightbulb.innerText = "unlit" } }) breakBulb.addEventListener("click", () => { lightbulb.innerText = "broken" isBroken = true })
But these checks are often the cause of undefined behavior in our apps: forms that submit unsuccessfully yet show a success message, or a query that fires when it shouldn’t. Writing boolean flags is extremely error-prone and not easy to read and reason about. Not to mention that our logic is now spread across multiple scopes and functions. Refactoring this behavior, would mean refactoring multiple functions or files, making it more error-prone.
This is where state machines fit perfectly. By defining your app’s behavior upfront, your UI becomes a mere reflection of your app’s logic. So let’s refactor our initial implementation to use state machines.
States and eventsLink to this section
We will define our machine as a simple object, that describes possible states and their transitions. Many state machine libraries resort to more complex objects to support more features.
<>Copyconst machine = { initial: "lit", states: { lit: { on: { OFF: "unlit", BREAK: "broken", }, }, unlit: { on: { ON: "lit", BREAK: "broken", }, }, broken: {}, }, }
Here’s a visualization of our state machine:
Our machine object comprises of an initial
property to set the initial state when we first open our app, and a states
objects holding every possible state our app can be in. These states
in turn have an optional on
object spelling out the events
that they react to. The lit
state reacts to an OFF
and BREAK
events. Meaning that when the light bulb is lit, we can either turn it off or break it. We cannot turn it on while it is on (although we can model our logic that way if we choose to.)
Final StatesLink to this section
The broken
state does not react to any event which makes it a final state — a state that does not transition to other states, ending the flow of the whole state machine.
TransitionsLink to this section
A transition is a pure function that returns a new state based on the current state and an event.
<>Copyconst transition = (state, event) => { const nextState = machine.states[state]?.on?.[event] return nextState || state }
The transition function traverses our machine’s states and their events:
<>Copytransition('lit', 'OFF') // returns 'unlit' transition('lit', 'BREAK') // returns 'broken' transition('unlit', 'OFF') // returns 'unlit' (unchanged) transition('broken', 'BREAK') // return 'broken' (unchanged) transition('broken', 'OFF') // returns 'broken' (unchanged)
If we are not handling a specific event in a given state, our state is going to remain unchanged, returning the old state. This is a key idea behind state machines.
Tracking and sending eventsLink to this section
To track and send events, we will define a state
variable that defaults to our machine’s initial state and a send function that takes an event and updates our state according to our machine’s implementation.
<>Copylet state = machine.initial const send = (event) => { state = transition(state, event) }
So, now instead of keeping up with our state through booleans and if statements, we simply dispatch our events and call for our UI to update:
<>CopyturnBulbOn.addEventListener("click", () => { send("ON") lightbulb.innerText = state }) turnBulbOff.addEventListener("click", () => { send("OFF") lightbulb.innerText = state }) breakBulb.addEventListener("click", () => { send("BREAK") lightbulb.innerText = state })
Our three buttons will work as intended, and whenever we break our light bulb, the user will not be able to turn it on or off.
State machines using XStateLink to this section
XState is a state management library that popularized the use of state machines on the web in recent years. It comes with tools to create, interpret, and subscribe to state machines, guard and delay events, handle extended state, and many other features.
To install XState, run npm install xstate
.
XState provides us with two functions to create and manage (track, send events, etc.) our machines: createMachine
and interpret
.
<>Copyimport { createMachine, interpret } from "xstate" const machine = createMachine({ initial: "lit", states: { lit: { on: { OFF: "unlit", BREAK: "broken", }, }, unlit: { on: { ON: "lit", BREAK: "broken", }, }, broken: {}, }, }) const service = interpret(machine) service.start() turnBulbOn.addEventListener("click", () => { service.send("ON") lightbulb.innerText = service.state.value }) turnBulbOff.addEventListener("click", () => { service.send("OFF") lightbulb.innerText = service.state.value }) breakBulb.addEventListener("click", () => { service.send("BREAK") lightbulb.innerText = service.state.value })
We can minimize our code further by subscribing to our state and updating the UI as a subscription:
<>CopyturnBulbOn.addEventListener("click", () => { service.send("ON") }) turnBulbOff.addEventListener("click", () => { service.send("OFF") }) breakBulb.addEventListener("click", () => { service.send("BREAK") }) service.subscribe((state) => { lightbulb.innerText = state.value })
You notice that we’re now using state.value
instead of state
, because XState exposes a number of useful methods and properties on the state
object, one of which is state.matches
:
<>Copystate.matches('lit') // true or false based on the current state state.matches('non-existent-state') // false
One other useful state method is the state.can
method, which returns true
or false
based on whether the current state handles a given event or not.
Thus, we can initially hide our Turn on
button and show/hide our buttons based on whether we can dispatch their related events:
<>Copy<p>The lightbulb is <span id="lightbulb">lit</span></p> <!-- hidden on page load --> <button hidden id="turn-on">Turn on</button> <button id="turn-off">Turn off</button> <button id="break">Break</button> <!-- reloads the page --> <button hidden id="reset" onclick="history.go(0)">Reset</button>
<>Copyconst lightbulb = document.getElementById("lightbulb") const turnBulbOn = document.getElementById("turn-on") const turnBulbOff = document.getElementById("turn-off") const breakBulb = document.getElementById("break") const reset = document.getElementById("reset") service.subscribe((state) => { lightbulb.innerText = state.value turnBulbOn.hidden = !state.can("ON") turnBulbOff.hidden = !state.can("OFF") breakBulb.hidden = !state.can("BREAK") reset.hidden = !state.matches("broken") })
So now, whenever our state changes, we can show and hide the appropriate buttons.
Actions and side effectsLink to this section
To run side effects inside our machine, XState has three types of actions: transition actions that run on an event, entry
actions that run when we enter a state, and exit
actions that run when we exist a state. entry
, exit
, and actions
can all be an array of functions (or even string references as we will see.)
<>Copyconst machine = createMachine({ initial: "open", states: { open: { entry: () => console.log("entering open..."), exit: () => console.log("exiting open..."), on: { TOGGLE: { target: "close", actions: () => console.log("toggling..."), }, }, }, close: {}, }, })
Context and extended stateLink to this section
When talking about state machines, we can distinguish between two types of states: finite state and extended state.
A person, for instance, can be either standing or sitting. They cannot be standing and sitting at the same time. They also can be either awake or asleep, and an array of other finite states. By contrast, a person can also have state that’s potentially infinite. E.g., their age, nicknames, or hobbies. This is called infinite or extended state. It helps to think about finite state as qualitative state while extended state is quantitative.
In our example, we will track how many times we switch our light bulb (as extended state) and display it in our message.
<>Copyimport { assign, createMachine, interpret } from "xstate" const machine = createMachine({ initial: "lit", context: { switchCount: 0 }, states: { lit: { entry: "switched", on: { OFF: "unlit", BREAK: "broken", }, }, unlit: { entry: "switched", on: { ON: "lit", BREAK: "broken", }, }, broken: {}, }, }).withConfig({ actions: { switched: assign({ switchCount: (context) => context.switchCount + 1 }), }, }) const service = interpret(machine) service.start() service.subscribe((state) => { lightbulb.innerText = `${state.value} (${state.context.switchCount})` turnBulbOn.hidden = !state.can("ON") turnBulbOff.hidden = !state.can("OFF") breakBulb.hidden = !state.can("BREAK") reset.hidden = !state.matches("broken") })
We now have a context
property that holds a switchCount
initiated with 0
. And as mentioned before, we added entry
actions to our lit
and unlit
states using string references and defined our functions using the withConfig
method on our machine to eliminate any code repetition.
To update our context inside the machine, XState provides an assign
function, which gets called to form an action and cannot be used within a function (e.g., actions: () => { assign(...) }
).
Also, notice that despite our default value of 0
for our switchCount
, XState will run our entry
action when our service starts, displaying a count of 1
in our UI.
GuardsLink to this section
Guards allow us to block a transition from happening given a condition (such as the outcome of an input validation.) Unlike actions, guards only apply to events, but they enjoy the same flexibility in their definition.
In our example, we will block any attempt to break a light bulb if the switch count exceeds 3
.
<>Copyconst machine = createMachine({ initial: "lit", context: { switchCount: 0 }, states: { lit: { entry: "switched", on: { OFF: "unlit", BREAK: { target: "broken", cond: "goodLightBulb" }, }, }, unlit: { entry: "switched", on: { ON: "lit", BREAK: { target: "broken", cond: "goodLightBulb" }, }, }, broken: {}, }, }).withConfig({ actions: { switched: assign({ switchCount: (context) => context.switchCount + 1 }), }, guards: { goodLightBulb: (context) => context.switchCount <= 3, }, })
We add guards in our events using the weirdly named cond
property (stands for condition), and we use the withConfig
guards
property to write our definition for the goodLightBulb
guard.
The service.can
we used earlier runs our guards to determine whether an event is possible to dispatch or not, which means that our UI will correctly remove the break
button once our condition is met. If our guard function fails to run, service.can
will return false
.
Eventless transitionsLink to this section
Let’s say that no matter how good our light bulb is, if the switch count reaches 10
, it should break.
To achieve that, we can use an eventless transtion using the always
property with a condition:
<>Copyconst machine = createMachine({ initial: "lit", context: { switchCount: 0 }, states: { lit: { /*...*/ }, unlit: { entry: "switched", on: { /*...*/ }, always: { cond: (context) => context.switchCount >= 10, target: "broken", }, }, broken: {}, }, })
What’s next?Link to this section
Despite covering the use of state machines to model our UI, state machines can be and are used everywhere. From handling data-fetching, loading, and error states to building complex interactive animations.
In this article, we covered key concepts behind state machines, events, and transitions, and we implemented a state machine in XState, making use of extended state, actions, guarded and eventless transitions. And while we updated our UI manually by subscribing to our machine’s service, XState supports almost all major frontend frameworks.
Comments (0)
Be the first to leave a comment