Timed Finite State Machines with React and XState
Altrim Beqiri /
Intro
For a while now I keep noticing tweets showing up on my timeline about state machines in React. Especially I keep stumbling upon the XState library. XState is a library for creating state machines and interpreting them. I’ve never used XState or other similar libraries before but a long time ago during my studies I've had to deal a bit with finite state machines. In particular I remember I enjoyed playing around with Timed Automatons. A timed automaton is a finite-state machine extended with clock variables. To demonstrate what a timed automaton is I will use XState with React.
We are going to build the following machine depicting a pretty simple lamp.
The lamp is composed of three states: Off
, Low
and Bright
. We move through the states by clicking on the button. If we click on the button then the lamp turns on. If we click on the button again then the lamp will turn off.
However, if we are fast and we rapidly click the button twice, the lamp turns on and becomes bright.
The clock of the lamp is used to detect if we were fast t < 3
or slow t >= 3
.
The clock t
is used as a guard
during the transitions. The clock starts ticking (is reset) when we move from the Off
state to the Low
state.
While we are in the Low
state the clock continues ticking and depending how long we stay in that state determines our next transition.
In this case if we act in less than 3 seconds t < 3
we would transition to the Bright
state, and from the Bright
state on the next transition we end up in the Off
state.
Otherwise if we stay longer than t >= 3
in the Low
state on the next transition from there we end up in the Off
state.
Implementation
Now that we have the model, let's see how the LampMachine.ts looks like with React and XState.
We create a simple machine with an id: "lamp"
that has only three states represented by the LampState
enum, and we set the initial state to LampState.Off
import { assign, createMachine, send } from "xstate";
enum LampState {
Off = "Off",
Low = "Low",
Bright = "Bright",
}
enum Event {
Tick = "Tick",
Reset = "Reset",
}
interface LampContext {
elapsed: number;
clockGuard: number;
interval: number;
}
export const createLampMachine = ({ elapsed, interval, clockGuard }: LampContext) =>
createMachine<LampContext>({
id: "lamp",
initial: LampState.Off,
context: {
elapsed,
interval,
clockGuard,
},
states: {
[LampState.Off]: {
...
},
[LampState.Low]: {
...
},
[LampState.Bright]: {
...
},
},
});
The LampState.Off
and LampState.Bright
transition implementation are pretty simple so we will look only into the LampState.Low
node where the interesting stuff happens.
Upon entering this node we invoke a service that starts a timer. Inside the interval we send an event Event.Tick
that is used to update the elapsed
counter in the context.
[LampState.Low]: {
// The timer service
invoke: {
src: (context) => (cb) => {
const interval = setInterval(() => {
// Send the event
cb(Event.Tick);
}, 1000 * context.interval);
return () => {
clearInterval(interval);
};
},
},
on: {
// On every tick
[Event.Tick]: {
actions: assign({
// Update the elapsed counter
elapsed: (context) => context.elapsed + context.interval,
}),
},
...
}
}
Additionally in the LampState.Low
state we also have the click
event implementation where we handle the guards.
In the click
event we specify an array with two conditions. In XState a condition function (also known as a guard) is specified on the .cond
property of a transition.
From the guard functions we return a boolean true
or false
, which determines whether the transition should be allowed to take place.
[LampState.Low]: {
...
on: {
...
click: [
{
// We transition to the `Bright` state if `t < 3`
target: [LampState.Bright],
cond: ({ elapsed, clockGuard }): boolean => {
return elapsed < clockGuard;
},
},
{
// Otherwise transition to the `Off` state if `t >= 3`
target: [LampState.Off],
cond: ({ elapsed, clockGuard }): boolean => {
return elapsed >= clockGuard;
},
},
],
}
}
In the above code we can see that if we satisfy the condition t < 3
we transition to the Bright
state.
Otherwise if the t >= 3
condition is satisfied we transition to the Off
state.
And that is more or less how we handle clock variables in the lamp state machine.
Finally if we take a look at the React component, the usage is straight forward. I am using Jotai for this example just for fun, but it can totally work without it.
import { atom, Provider, useAtom } from "jotai";
import { atomWithMachine } from "jotai/xstate";
// We initialize the `lampMachineAtom` with some default values.
// We can specify the guard depending how long we want to stay in the `Low` state.
// In this example we are waiting 3 seconds
const defaultAtom = atom({ elapsed: 0, interval: 0.1, clockGuard: 3 });
const lampMachineAtom = atomWithMachine((get) => createLampMachine(get(defaultAtom)));
const Lamp: React.FC = () => {
const [state, send] = useAtom(lampMachineAtom);
const { elapsed, clockGuard } = state.context;
...
const getLampState = () => {
if (state.matches(LampState.Off)) {
return LampState.Off;
}
if (state.matches(LampState.Low)) {
return LampState.Low;
}
if (state.matches(LampState.Bright)) {
return LampState.Bright;
}
return LampState.Off;
};
return (
<>
...
{/* The Button we use to control the lamp */}
<Switch onClick={send}>{getLampState()}</Switch>
{/* Timer to see the clock running */}
<h2 className={styles.counter}>t={elapsed.toFixed(1)}s</h2>
{/* The Automaton to show the simulation step by step */}
<Automaton light={getLampState()} clockGuard={clockGuard} elapsed={elapsed} />
<LightBulb light={getLampState()} />
...
</>
);
}
And pretty much that's it. You can find the entire source code on GitHub if you want to check it out.
Demo Codesandbox
References
I had a lot of fun playing around with XState this weekend, most likely I will use it in the future as well.
Are you using XState in your projects? Tweet me at @altrimbeqiri and let me know the cool stuff you build with it.
Happy Coding!