Ever worked with building complex UIs? If yes, you probably know how challenging it can be to keep track all UI states. It's very easy to end up with a big hairy ball of all the state variables.
If you are building semi-complex UIs Finite State Machines can help you deal with state. Learn what FSMs are and how you can use them with a simple example.
What is a Finite State Machine?
Shortly, Finite State Machine or FSM is an old, but recently rediscovered pattern, to help us deal with complex state.
The gist of FSM is that your application can only be in one state at a time. If you think about it, it actually makes sense. Just like in real life, you can only be in one state at at time. Unless you are a quark.
I find Finite State Machines a great fit when working with complex UIs. Applications are getting more and more interactive and keeping track of UI state can quickly get challenging.
What is Robot FSM?
There are currently two popular FSM libraries in JavaScript that I know of. XState and Robot.
I would say that XState is the most popular. It has a lot of features and really great documentation. However, FSM can be intimidating to learn and XState especially, because there you can achieve the same result by different ways.
If you are interested in XState, I've written an article where I used XState as a state machine for Firebase Authentication - Firebase authentication with XState and Svelte.
For this example I decided to use Robot FSM library. First, it's much smaller than XState - 1kb vs 13kb. Second, it's also easier to learn, because it has less bells and whistles.
Robot doesn't have as good documentation as XState, so you have to read between the lines and experiment a bit to understand it's concepts and how to work with it.
But that's why I am here! Hopefully by the end of this article everything will click, you will know what an FSM is and understand where you can use them.
I promise to keep things simple. Let's code!
Setting up project
First, we need to create a new Svelte project. We will use Svite with pnpm package manager to save some disk space. I find this combination the fastest way to get going.
$ npx svite@beta create -pm pnpm svelte-robot-fsm-example
We will be building a panel menu with different sections. Think tabs component, only prettier.
Next step is to create a few Svelte components.
Creating panel components
We will create a simple panel component called Section.svelte
. It will take a title
and a close
function handler as parameters.
Close handler will be wired up to the Close button in the component. When clicked, it should hide the menu. We will also add Svelte's slide transition for some neat visual FX.
<!-- src/components/Section.svelte -->
<script>
import { slide } from 'svelte/transition';
export let title = '';
export let close = () => {};
</script>
<style>
.section {
border-bottom: 1px solid #65b6ca;
}
.inner {
position: relative;
padding: 1em;
margin: 0 auto;
max-width: 100ch;
min-height: 200px;
}
h2 {
font-weight: 700;
font-size: 3em;
}
.close {
position: absolute;
bottom: 2em;
}
</style>
<div class="section" transition:slide>
<div class="inner">
<h2>{title}</h2>
<button on:click={close} class="close">Close</button>
</div>
</div>
Let's setup three sections in our App.svelte
. Replace the file with the code below.
<!-- App.svelte -->
<script>
import Section from './components/Section.svelte';
</script>
<style>
.wrapper {
font-family: sans-serif;
}
.container {
margin: 1em auto;
max-width: 100ch;
}
.panel {
background-color: #b9f0fe;
}
.buttons > button {
padding: 0.5em 1em;
}
</style>
<div class="wrapper">
<div class="heading container">
<h1>Svelte with Robot FSM Example</h1>
</div>
<div class="panel">
<Section title="One" />
<Section title="Two" />
<Section title="Three" />
</div>
<div class="buttons container">
<button>One</button>
<button>Two</button>
<button>Three</button>
</div>
</div>
If you start the app (pnpm run dev), all of the three sections should be visible. That's not something we want. Let's add some interactivity logic next.
Doing it the normal way
Adding interactivity to these panel sections is straight forward. For that we need to add three boolean variables, one for each section, and then manipulate their state.
Here is the updated App.svelte
file with state variables and on:click
handlers.
<!-- App.svelte -->
<script>
import Section from './components/Section.svelte';
// open/close state variables. one for each section.
let showOne = false;
let showTwo = false;
let showThree = false;
</script>
<style>
.wrapper {
font-family: sans-serif;
}
.container {
margin: 1em auto;
max-width: 100ch;
}
.panel {
background-color: #b9f0fe;
}
.buttons > button {
padding: 0.5em 1em;
}
</style>
<div class="wrapper">
<div class="heading container">
<h1>Svelte with Robot FSM Example</h1>
</div>
<div class="panel">
{#if showOne}
<Section title="One" close={() => (showOne = false)} />
{/if}
{#if showTwo}
<Section title="Two" close={() => (showTwo = false)} />
{/if}
{#if showThree}
<Section title="Three" close={() => (showThree = false)} />
{/if}
</div>
<div class="buttons container">
<button on:click={() => (showOne = true)}>One</button>
<button on:click={() => (showTwo = true)}>Two</button>
<button on:click={() => (showThree = true)}>Three</button>
</div>
</div>
You can see that we added basic on:click
handlers to buttons and we also pass in close
handlers that set state variable to false
.
If you've done everything right you should now be able to open and close each panel. Good times!
But what about the scenario that only one section should be visible at one time? Meaning if you open a new one, the currently visible section should close.
It's doable, but things can get pretty hairy, because you have to track individual state of every section. That's something Finite State Machines can help us with.
Let's bring in the Robot
Let's add Robot FSM library to our project.
$ pnpm add robot3
Now we need to create our Finite State Machine. We will name it panelMachine
as it will be responsible for keeping track of our panel's state.
Add this code to App.svelte
and I will explain it in a second.
<!-- App.svelte -->
<script>
// ...
import { createMachine, state, transition } from 'robot3';
const panelMachine = createMachine({
closed: state(
transition('showOne', 'one'),
transition('showTwo', 'two'),
transition('showThree', 'three')
),
one: state(transition('close', 'closed')),
two: state(transition('close', 'closed')),
three: state(transition('close', 'closed')),
});
</script>
As you can see we used Robot's createMachine
function to define a state machine. The machine consists of total four states - closed, one, two and three. Our first state is closed and that will be the state machine will start with. If you look carefully you can see that our state machine is just a simple dictionary.
Each dictionary value is then defined by a state
function. The state function is used to define state in Robot FSM library. But what going on inside the state functions?
State function is the meat of the Robot library. It's in there that you define your state and transitions to other states. Transitions? States? What?!
Yes, I understand if you are confused and I promised to keep things simple for that reason. Let me explain.
As I wrote earlier, an state machine can only be in one state at at time. When our panel FSM is started it starts in the closed
state, because it's first in the dictionary.
If you imagine that you are in the closed
state you can see that inside it we have defined three transition
functions. Transition function dictates the states you can go to from the state FSM that is currently in. They are used to move from one state to another.
Every transition function takes a trigger event as first argument and the destination state as second argument. You can name events however you like, but I like to prefix mine with verbs as I like to think of them not as events, but rather commands or actions. By sending an event to the state machine you command it to go to another state than it's currently in.
One important concept to understand is that you can only move those states that you have setup transitions for. So, from our closed
state we can only go to states one
, two
or three
. If you send in some other event name than showOne
, showTwo
or showThree
our FSM will simply ignore them and just stay in the current state it is in.
Similarly, you can only go to closed
state from other states by sending a close
event to our state machine. Are you with me so far?
Interpreting our state machine
Alright, we have defined our state machine. Now we have to activate it by using interpret
function from the Robot library.
The interpret
function takes a machine and wraps it into a service that can send events into the machine to change its states. A service does not mutate a machine. Instead it creates derived machines with the current state set.
You can find the current state of the machine by the machine.current
property. If you wrap it in the service it will be located under service.machine.current
.
Here is the code that demonstrates how a machine service works.
// Wrap machine into a service. Every time state changes it's printed.
// A service has a `send` command that we can use to send it events.
const service = interpret(panelMachine, service => {
console.log('current state: ', service.machine.current);
});
console.log('current state: ', s.machine.current)
// current state: closed
// Transition from `closed` state to `one` [legal]
service.send('showOne');
// current state: one
// Transition from `one` to `two` [illegal]
service.send('showTwo');
// current state: one
// Send non-existent event
service.send('foo');
// current state: one
// Transition from `one` to `closed` [legal]
service.send('close');
// current state: closed
As you can see from the example above, if we send in a valid event, that we defined in our state transition in the current state we are in, we can switch to a different state in our state machine.
Wrapping our state machine service in a Svelte store
Robot does not have Svelte support per default, but it's not hard to wire it up. For that we will can write a custom Svelte store.
import { createMachine, state, transition, interpret} from 'robot3';
import { writable } from 'svelte/store';
export const useMachine = machine => {
const { subscribe, set } = writable({
state: machine.current,
context: machine.context,
});
const service = interpret(machine, service => {
set({ state: service.machine.current, context: service.context });
});
return [{ subscribe }, service.send];
};
As you can see we defined a useMachine
constructor function. It takes a Robot FSM as its argument and returns an array. First value is our custom Svelte store with our machine's context
and state
as two properties. Second value is the service send
method. It will allow us to send events to our machine.
Let's use it in our code.
const [panelState, send] = useMachine(panelMachine);
Using state machine
We have everything we need. Next step we need to do is to replace our on:click
and close
handlers. We also need to use our new panelService
store for state checking instead of individual state variables.
I won't bore you with all the code changes needed. Instead, here is the updated App.svelte
file in all its glory.
<!-- App.svelte -->
<script>
import Section from './components/Section.svelte';
import { createMachine, state, transition, interpret } from 'robot3';
import { writable } from 'svelte/store';
// constructor function to interpret the machine and wrap
// the FSM service into a custom svelte store.
export const useMachine = (machine) => {
const { subscribe, set } = writable({
state: machine.current,
context: machine.context,
});
// every time the state changes we will update our Svelte store
const service = interpret(machine, (service) => {
set({ state: service.machine.current, context: service.context });
});
return [{ subscribe }, service.send];
};
// create our Robot FSM definition
const panelMachine = createMachine({
closed: state(
transition('showOne', 'one'),
transition('showTwo', 'two'),
transition('showThree', 'three')
),
one: state(transition('close', 'closed')),
two: state(transition('close', 'closed')),
three: state(transition('close', 'closed')),
});
const [panelState, send] = useMachine(panelMachine);
// close handler
const close = () => send('close');
// send in event to our FSM
const toggle = (action) => send(action);
</script>
<style>
.wrapper {
font-family: sans-serif;
}
.container {
margin: 1em auto;
max-width: 100ch;
}
.panel {
background-color: #b9f0fe;
}
.buttons > button {
padding: 0.5em 1em;
}
</style>
<div class="wrapper">
<div class="heading container">
<h1>Svelte with Robot FSM Example</h1>
</div>
<div class="panel">
{#if $panelState.state === 'one'}
<Section title="One" {close} />
{/if}
{#if $panelState.state === 'two'}
<Section title="Two" {close} />
{/if}
{#if $panelState.state === 'three'}
<Section title="Three" {close} />
{/if}
</div>
<div class="buttons container">
<button on:click={() => toggle('showOne')}>One</button>
<button on:click={() => toggle('showTwo')}>Two</button>
<button on:click={() => toggle('showThree')}>Three</button>
</div>
</div>
You can see that we have replaced our individual state variables with our state machine. We have also create two handlers - close
and toggle
.
If you start the app now, you will notice that we can only open one panel at a time. But I must say, it's not a correct behaviour. We cannot open other panel before we close the currently open one.
Why? If you look at our machine definition you can see that the only valid transition from a panel is to the closed
state. This means the only valid event we can sent to the open panel is close
event.
We can actually use this knowledge to our advantage by changing our toggle
handler to the following.
const toggle = (action) => {
send('close');
send(action);
};
Before sending our show event, we first send the close
event so that our machine enters closed
state.
I don't know about you, but I find it pretty elegant.
Implementing toggle functionality
But what if we want our button not only to open, but also toggle a panel's state. Meaning if the panel is currently open clicking the same button should close it.
For that we need add a guard that checks if the current state our machine matches the desired state. If it does, it just closes the panel.
const toggle = (action, state) => {
// if current state matches desired panel state, close it
if ($panelState.state === state) {
send('close');
return;
}
// else first close, then open desired state
send('close');
send(action);
};
We also need to provide the desired state in our buttons' on:click
handlers.
<div class="buttons container">
<button on:click={() => toggle('showOne', 'one')}>One</button>
<button on:click={() => toggle('showTwo', 'two')}>Two</button>
<button on:click={() => toggle('showThree', 'three')}>Three</button>
</div>
Let's take a look if the toggle works.
Yep. Works dandy fine! Mission accomplished.
Conclusion
Finite State Machines are hard to understand if you haven't been exposed to them before. They also offer so much more than I showed in my example.
In Robot there is also context that lets you work with state, guards that allows you to enter a state only if condition is met, invoke that helps us work with promises and sub-machines, and immediate transitions.
XState has even more to offer. It's easy to get lost if you don't have a solid understanding of the basics first.
I chose the simplest example I could think of on purpose that only shows how to setup state and transition from one state to another by sending events.
If you want to test you skills try building a tabs component. The concepts are exactly the same and you can re-use a lot of the code we have written.
When you understand the concepts and how to work with state, all other FSM concepts become easy. In the end, it's all about states, sending events and transitions.
Start simple, experiment and I can guarantee that you will quickly start seeing places in your codebase where a state machine makes sense to use. Plus, as a bonus, you will get one more powerful tool in your toolbox.
You can find the full code at https://github.com/codechips/svelte-robot-fsm-example
Thanks for reading! Now go and replace something in your codebase with an FSM FTW.
Top comments (0)