Taking a look at finite state machines
2019-11-07 | Originally published at dev.to | 6 min read | Leer en españolThe finite who-- what?
It is a way of modeling the behavior of a system. The idea is that your "system" can only be in one state at any given time, and an input (or event) can trigger the transition to another state.
What kind of problems does it solve?
Invalid state. How many times have you used a flag or attribute like "disabled" to prevent a user from doing something they shouldn't do? By setting the rules of our system we can avoid these kind of problems.
How does that look like in javascript?
I'm very glad you asked. The real reason I'm writing this is to show you a library that I saw the other day. We are going to use robot3 to built a random quote machine.
We will make a card that displays a quote and below that we'll have a button that will fetch another quote.
We'll do it one step at a time. Let's first prepare the states.
Our card will be either idle
or loading
. Create a machine with that.
import {
createMachine,
state,
interpret
} from 'https://unpkg.com/robot3@0.2.9/machine.js';
const mr_robot = createMachine({
idle: state(),
loading: state()
});
In here each state
is a key in the "setup object" that we pass to createMachine
, but also notice that it needs to be a state
object, which we create with the state
function.
Now we need transitions. Our idle
state will switch to loading
if a fetch
event happens, loading
will go back to idle
if a done
is dispatched.
import {
createMachine,
state,
+ transition,
interpret
} from 'https://unpkg.com/robot3@0.2.9/machine.js';
const mr_robot = createMachine({
- idle: state(),
- loading: state()
+ idle: state(transition('fetch', 'loading')),
+ loading: state(transition('done', 'idle'))
});
transition
is the thing that connects our states. It's first parameter is the name of the event that will trigger the transition, the second parameter is the "destination" state it will switch to. The rest of transition
's parameters can be a list of function that will be executed when this transition is triggered.
Looks lovely, but uhm... how do we test it? The machine by itself doesn't do anything. We need to give our new machine to the interpret
function which will give us a "service" that can dispatch events. To prove that we are actually doing something we'll also give a handler to interpret
, it will be like a 'onchange', it will listen to state changes.
const handler = ({ machine }) => {
console.log(machine.current);
}
const { send } = interpret(mr_robot, handler);
Now you can see if it's alive.
send('fetch');
send('fetch');
send('fetch');
send('done');
// You should see in the console
// loading (3)
// idle
Dispatching fetch
will turn the current state to loading
and done
will get it back to idle
. I see you're not impressed. That's fine. Let's try something, let's add another state end
and make loading
switch to that, then dispatch done
and see what happens.
const mr_robot = createMachine({
idle: state(transition('fetch', 'loading')),
- loading: state(transition('done', 'idle'))
+ loading: state(transition('done', 'end')),
+ end: state()
});
send('done');
// You should see in the console
// idle
Sending done
while idle
doesn't trigger a loading
state, it stays in idle
because that state doesn't have a done
event. And now...
// We do the usual flow.
send('fetch');
send('done');
// You should have
// loading
// end
// Now try again `fetch`
send('fetch');
// You should have
// end
If you send fetch
(or any other event) while in end
state will give you end
every single time. Why? Because you can't go anywhere, end
doesn't have transitions.
I hope you see why this is useful. If not, I apologize for all the console.log
ing.
Going back to our current machine. This what we got so far.
import {
createMachine,
state,
transition,
interpret
} from 'https://unpkg.com/robot3@0.2.9/machine.js';
const mr_robot = createMachine({
idle: state(transition('fetch', 'loading')),
loading: state(transition('done', 'idle'))
});
const handler = ({ machine }) => {
console.log(machine.current);
}
const { send } = interpret(mr_robot, handler);
But this is still not enough, now we need to get some data when we enter the loading
state. Let's first fake our quote fetching function.
function get_quote() {
// make a random delay, 3 to 5 seconds.
const delay = random_number(3, 5) * 1000;
const promise = new Promise(res => {
setTimeout(() => res('<quote>'), delay);
});
// sanity check
promise.then(res => (console.log(res), res));
return promise;
}
To make it work with our state machine we will use a function called invoke
, this utility calls an "async function" (a function that returns a promise) when you enter a state
then when the promise resolves it sends a done
event (if it fails it sends a error
event).
import {
createMachine,
state,
+ invoke,
transition,
interpret
} from 'https://unpkg.com/robot3@0.2.9/machine.js';
const mr_robot = createMachine({
idle: state(transition('fetch', 'loading')),
- loading: state(transition('done', 'idle')),
+ loading: invoke(get_quote, transition('done', 'idle')),
});
If you test send('fetch')
you should see in the console.
loading
// wait a few seconds...
<quote>
idle
By now I hope you're all wondering where do we actually keep the data? There is a handy feature in createMachine
that let us define a "context" object that will be available to us in the function that we attach to our transitions
.
const context = ev => ({
data: {},
});
const mr_robot = createMachine({
idle: state(transition('fetch', 'loading')),
loading: invoke(get_quote, transition('done', 'idle')),
- });
+ }, context);
Next we'll use another utility. We will pass a third parameter to loading
's transition, a hook of some sort that will modify the context object. This utility is called reduce
and it looks like this.
reduce((ctx, ev) => ({ ...ctx, data: ev.data }))
It takes the current context, a payload (here named ev
) and whatever you return from it becomes your new context. We add that to the loading
state.
import {
createMachine,
state,
invoke,
transition,
+ reduce,
interpret
} from 'https://unpkg.com/robot3@0.2.9/machine.js';
const mr_robot = createMachine({
idle: state(transition('fetch', 'loading')),
- loading: invoke(get_quote, transition('done', 'idle')),
+ loading: invoke(
+ get_quote,
+ transition(
+ 'done',
+ 'idle',
+ reduce((ctx, ev) => ({ ...ctx, data: ev.data }))
+ )
+ ),
}, context);
Sanity check time. How do we know that works? We modify interpret
's handler.
const handler = ({ machine, context }) => {
console.log(JSON.stringify({
state: machine.current,
context
}));
}
You should see this.
{'state':'loading','context':{'data':{}}}
// wait a few seconds...
{'state':'idle','context':{'data':'<quote>'}}
We are ready. Let's show something in the browser.
<main id="app" class="card">
<section id="card" class="card__content">
<div class="card__body">
<div class="card__quote">
quote
</div>
<div class="card__author">
-- author
</div>
</div>
<div class="card__footer">
<button id="load_btn" class="btn btn--new">
More
</button>
<a href="#" target="_blank" class="btn btn--tweet">
Tweet
</a>
</div>
</section>
</main>
body {
display: flex;
justify-content: center;
align-items: center;
min-height: 95vh;
background: #ddd;
font-size: 1em;
color: #212121;
}
.card {
width: 600px;
background: white;
box-shadow: 0 2px 5px 0 rgba(0, 0, 0, 0.16), 0 2px 10px 0 rgba(0, 0, 0, 0.12);
}
.card__content {
color: #212121;
padding: 20px;
}
.card__content--loader {
height: 95px;
display: flex;
align-items: center;
justify-content: center
}
.card__body {
padding-bottom: 15px;
}
.card__author {
padding-top: 10px;
font-style: italic;
}
.card__footer {
width: 100%;
display: flex;
justify-content: space-between;
}
.btn {
color: #fff;
cursor: pointer;
margin-top: 10px;
margin-left: 10px;
border-radius: 0.4rem;
text-decoration: none;
display: inline-block;
padding: .3rem .9rem;
}
.btn--new {
background-color: #2093be;
border: 0.1rem solid #2093be;
}
.btn--tweet {
background-color: #0074d9;
border: 0.1rem solid #0074d9;
}
.btn:hover {
background: #3cb0fd;
border: 0.1rem solid #3cb0fd;
text-decoration: none;
}
.hide {
display: none;
}
Now the last piece of the puzzle, the side effects. We need to attach another function to our transitions so we can update the DOM. We could use reduce
again but is just rude to have side effects on something called reduce
(just don't) We will bring another utility made for that, action
.
But first we must prepare. Update the context object with the necessary dependencies. (This step is not necessary, this is just me being allergic to global variables)
const context = ev => ({
data: {},
+ dom: {
+ quote: document.querySelector('.card__quote'),
+ author: document.querySelector('.card__author'),
+ load_btn: window.load_btn,
+ tweet_btn: document.querySelector('.btn--tweet'),
+ card: window.card
+ }
});
Create the side effects. At this point you should make sure that get_quote
actually returns an object with a quote
and author
property.
function update_card({ dom, data }) {
dom.load_btn.textContent = 'More';
dom.quote.textContent = data.quote;
dom.author.textContent = data.author;
const web_intent = 'https://twitter.com/intent/tweet?text=';
const tweet = `${data.quote} -- ${data.author}`;
dom.tweet_btn.setAttribute(
'href', web_intent + encodeURIComponent(tweet)
);
}
function show_loading({ dom }) {
dom.load_btn.textContent = 'Loading...';
}
Put everything together.
import {
createMachine,
state,
invoke,
transition,
reduce,
+ action,
interpret
} from 'https://unpkg.com/robot3@0.2.9/machine.js';
const mr_robot = createMachine({
- idle: state(transition('fetch', 'loading')),
+ idle: state(transition('fetch', 'loading', action(show_loading))),
loading: invoke(
get_quote,
transition(
'done',
'idle',
reduce((ctx, ev) => ({ ...ctx, data: ev.data })),
+ action(update_card)
)
),
}, context);
By now everything kinda works but it looks bad when it loads for the first time. Let's make another loader, one that hides the card while we fetch the first quote.
Let's start with the HTML.
<main id="app" class="card">
- <section id="card" class="card__content">
+ <section class="card__content card__content--loader">
+ <p>Loading</p>
+ </section>
+ <section id="card" class="hide card__content">
<div class="card__body">
<div class="card__quote">
quote
</div>
<div class="card__author">
-- author
</div>
</div>
<div class="card__footer">
<button id="load_btn" class="btn btn--new">
More
</button>
<a href="#" target="_blank" class="btn btn--tweet">
Tweet
</a>
</div>
</section>
</main>
We'll make another state, empty
. We can reuse our original loading
state for this. Make a factory function that returns the loading transition.
const load_quote = (...args) =>
invoke(
get_quote,
transition(
'done',
'idle',
reduce((ctx, ev) => ({ ...ctx, data: ev.data })),
...args
),
transition('error', 'idle')
);
const mr_robot = createMachine({
idle: state(transition('fetch', 'loading', action(show_loading))),
- loading: invoke(
- get_quote,
- transition(
- 'done',
- 'idle',
- reduce((ctx, ev) => ({ ...ctx, data: ev.data })),
- action(update_card)
- )
- ),
+ loading: load_quote(action(update_card))
}, context);
Now we use this to hide the first loader and show the quote when it's ready.
const context = ev => ({
data: {},
dom: {
quote: document.querySelector('.card__quote'),
author: document.querySelector('.card__author'),
+ loader: document.querySelector('.card__content--loader'),
load_btn: window.load_btn,
tweet_btn: document.querySelector('.btn--tweet'),
card: window.card
}
});
function hide_loader({ dom }) {
dom.loader.classList.add('hide');
dom.card.classList.remove('hide');
}
const mr_robot = createMachine({
+ empty: load_quote(action(update_card), action(hide_loader)),
idle: state(transition('fetch', 'loading', action(show_loading))),
loading: load_quote(action(update_card))
}, context);
-
- const handler = ({ machine, context }) => {
- console.log(JSON.stringify({
- state: machine.current,
- context
- }));
- }
+ const handler = () => {};
const { send } = interpret(mr_robot, handler);
+
+ const fetch_quote = () => send('fetch');
+
+ window.load_btn.addEventListener('click', fetch_quote);
Let's see it work.
See the Pen Finite Random Quote Machine by Heiker (@VonHeikemen) on CodePen.
So is this state machine thing helpful?
I hope so. Did you notice we made a bunch of test and created the blueprint of the quote machine even before writing any HTML? I think that's cool.
Did you try to click the 'loading' button while loading? Did it triggered a bunch of call to get_quote
? That is because we made (sort of) impossible that a fetch
event can happen during loading
.
Not only that, the behavior of the machine and the effects on the outside world are separated. Depending on how you like to write code that may be a good or a bad thing.
Want to know more?
Have any question? Feel free to leave a comment in one of these platform where I have shared this:
You can reach out to me on social media:
- Twitter @VonHeikemen_
- Bluesky @vonheikemen.bsky.social
- Mastodon @vonheikemen@hachyderm.io
Thank you for reading. If you find this article useful and want to support my efforts, buy me a coffee ☕