DEV Community

Ilia Mikhailov
Ilia Mikhailov

Posted on • Originally published at codechips.me on

Managing Svelte UI state with Robot FSM

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

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

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

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

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!

State based panel

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

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

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

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

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

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

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.

Robot FSM based panel

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

Before sending our show event, we first send the close event so that our machine enters closed state.

Robot FSM based panel with auto-switching

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

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

Let's take a look if the toggle works.

Robot FSM based panel with toggle

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)