DEV Community

Heiker
Heiker

Posted on • Edited on

Taking a look at finite state machines

Puedes leer la versión en español aquí.

The 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()
});
Enter fullscreen mode Exit fullscreen mode

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'))
 });
Enter fullscreen mode Exit fullscreen mode

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);
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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()
 });
Enter fullscreen mode Exit fullscreen mode
send('done');

// You should see in the console
// idle
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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.loging.

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);
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

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')),
 });
Enter fullscreen mode Exit fullscreen mode

If you test send('fetch') you should see in the console.

loading

// wait a few seconds...

<quote>
idle
Enter fullscreen mode Exit fullscreen mode

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: {},
});
Enter fullscreen mode Exit fullscreen mode
  const mr_robot = createMachine({
    idle: state(transition('fetch', 'loading')),
    loading: invoke(get_quote, transition('done', 'idle')),
- });
+ }, context);
Enter fullscreen mode Exit fullscreen mode

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 }))
Enter fullscreen mode Exit fullscreen mode

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);
Enter fullscreen mode Exit fullscreen mode

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
  }));
}
Enter fullscreen mode Exit fullscreen mode

You should see this.

{'state':'loading','context':{'data':{}}}

// wait a few seconds...

{'state':'idle','context':{'data':'<quote>'}}
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode
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;
}
Enter fullscreen mode Exit fullscreen mode

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
+  }
 });
Enter fullscreen mode Exit fullscreen mode

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...';
}
Enter fullscreen mode Exit fullscreen mode

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);
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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')
  );
Enter fullscreen mode Exit fullscreen mode
 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);
Enter fullscreen mode Exit fullscreen mode

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
   }
 });
Enter fullscreen mode Exit fullscreen mode
function hide_loader({ dom }) {
  dom.loader.classList.add('hide');
  dom.card.classList.remove('hide');
}
Enter fullscreen mode Exit fullscreen mode
 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);
Enter fullscreen mode Exit fullscreen mode

Let's see it work.

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?

XState (concepts)
robot3 - docs
Understanding State Machines


Thank you for your time. If you find this article useful and want to support my efforts, consider leaving a tip in ko-fi.com/vonheikemen.

buy me a coffee

Top comments (2)

Collapse
 
artydev profile image
artydev • Edited

Thnak you very much
You can also look a demo here RobotFetch

Collapse
 
trainingmontage profile image
Jeff Caldwell

I just wanted to thank you for writing this. I've been struggling with the xstate documentation all day and I'm so happy there's simpler library available.

Your example is well presented and I'm ready to use finite state machines in my code.