Łukasz Makuch

Łukasz Makuch

State machines to the rescue of complex forms

Complex forms may have many names, like wizards or creators, but they are always a challenge to implement and even a greater challenge to modify. A little change in the drawing on the whiteboard may lead to plenty of changes of if statements, session variables and routes.

The hidden machine

Very often during the process of development the whiteboard gets full of drawings representing different looks and behaviors connected with arrows named after some events. It helps us to understand the complex nature of the system we're building by taking away all of those constructions like assignments or conditional statements. Unfortunately the actual code rarely reminds of those drawings, even though they often look exactly like one of the fundamental concepts in computer science - state machines.

Sadly, state machines seem to be an underestimated idea in the world of web applications. Why is that? One of the reasons may be the fact that all the examples we can easily find differ a lot from the reality of back-end programming. A wonderful model of a vending machine or a turnstile may look appealing, but in the end it's nothing we need in our web app. A function that given a string gives another string still isn't a ready to use solution. Of course there are also different reasons, like open questions about storage mechanisms or solving the problem of state explosion.

When nodes and arrows become first-class citizens

In this article we're going to take a look at a wizard implemented around the idea of state machines, where things like methods, variables or conditionals are implementation details.

The goal and the toolchain

The goal of that wizard is to take orders for things to drink. It's implemented in node with express and rosmaro. I'll skip the process of building it, because it's intentionally not the simplest example. I'd like to focus on what does the code look like when things get complicated.

wizard

The scary part is the wall of text that could be called "requirements". The wizard visible above is relatively complex, and so is the description. To save you the pain I removed the boring parts and left only what indicates the complexity:

At the beginning [...] then we display a screen [...] it may be skipped [...] consists of few steps [...] First we see [...] If we picked a cold drink [...] Otherwise under the select field [...] if we previously entered the correct promo code [...] From that final screen we go back [...] then before picking the drink [...]

Understanding that endless stream of ifs is very hard.

From the drawing into the code

After reading the whole description (what you luckily don't need to do) it becomes clear that it always begins with a promo code screen and then we enter the path for either a valid or invalid promo code.

So the drawing of the main graph would look like this:

wizard

What can be directly reflected in the code:

const main = () => ({
  type: "graph",
  start: "promo_code",
  arrows: {

    promo_code: {
      ok: "ordering_with_promo_code",
      wrong: "ordering_without_promo_code",
      skipped: "ordering_without_promo_code"
    },

    ordering_without_promo_code: {
      go_back: "promo_code"
    }

  },
  nodes: {
    promo_code: promo_code(),
    ordering_with_promo_code: ordering_with_promo_code(),
    ordering_without_promo_code: ordering_without_promo_code()
  }
})

Those factory functions like promocode, _orderingwithpromocode_ and orderingwithoutpromocode_ return other graphs built in the same way.

The screen responsible for picking the drink has a finite number of variants. Great! Here goes the sketch:

wizard

And here's the code:

const pick_drink = () => ({
  type: "graph",
  start: "nothing_configured",
  arrows: {

    nothing_configured: {
      picked_hot_drink: "pick_cooler",
      picked_cold_drink: "cold_drink_configured"
    },

    pick_cooler: {
      picked_cooler: "hot_drink_configured",
      picked_cold_drink: "cold_drink_configured"
    },

    hot_drink_configured: {
      picked_hot_drink: "hot_drink_configured",
      picked_cooler: "hot_drink_configured",
      picked_cold_drink: "cold_drink_configured"
    },

    cold_drink_configured: {
      picked_hot_drink: "pick_cooler",
      picked_cold_drink: "cold_drink_configured"
    }

  },
  nodes: {
    nothing_configured: nothing_configured(),
    cold_drink_configured: cold_drink_configured(),
    pick_cooler: pick_cooler(),
    hot_drink_configured: hot_drink_configured()
  }
})

We can clearly see 4 nodes connected with arrows. Let's take a closer look at the factory of the screen called "cold drink configured":

const cold_drink_configured = () => ({
  type: "composite",
  nodes: [
    ["type", change_drink_type()],
    ["sugar", set_sugar()],
    ["nav", pick_cold_button()]
  ]
})

Again, it closely reflects the sketch. It has a separated way to change the drink type, set the sugar and a button that finally picks the configured cold drink.

So far there have been no if statements. Is that approach totally free of them? No, it's not. There are some, just deeper. Let's dig then. That's the code of the changedrinktype factory:

const change_drink_type = view(() => ({

  render() {
    const picked = this.context.drink

    return html`
      <select class="auto-submit" name="${safeHtml`${this.pname('drink')}`}">
        ${["coffee", "tea", "lemonade"].map(drink => safeHtml`
          <option value="${drink}" ${picked == drink ? "selected" : ""}>${drink}</option>
        `)}
      </select>
    `
  },

  handle(params) {
    const picked = params['drink']
    if (picked) {
      const is_hot = ["coffee", "tea"].includes(picked)
      const arrow = is_hot ? "picked_hot_drink" : "picked_cold_drink"
      this.follow(arrow, {...this.context, ...{drink: picked}})
    }
  }

}))

What does this object do? It renders a list of all drinks and selects the currently picked one. It also handles the form data. How does it do it? It has a few if statements! One to check whether a drink is selected, another one to check whether we picked some drink and yet another one to determine whether that drink is hot! But aren't if statements definitely harmful? I don't think so. I'd rather say they are very much like cakes - it's a great thing to have a piece of cake, but eating the whole cake could make us sick. Those little if statements actually turn out to be very helpful, because they save us from facing a problem called state explosion. Imagine how messy would it be if we needed a separate node for each drink just to display the selected one! So instead of sticking to the idea of pure FSM, we add some if statements where it's handy to make the graph more descriptive and less bloated. The important part is that the code is free of if statements that would just introduce some noise.

It's a lot harder to make unwanted elements appear on the screen out of nowhere, because we made a mistake in an if statement, when there's no if statement to decide if they should be visible or hidden. Their placement within the graph tells enough.

No more /step1, /step2a, /step2b, /step2bfinal, /step3...

A pattern that may be often seen is to create a bunch of GET endpoints for every step of the wizard. Unfortunately, things get very complicated when it comes to access control. Let's say the user hasn't entered the valid promo code, but she's trying to open the /order-wizard/ordering-with-promo-code address. We don't want any insecure direct access like that. So we put a bunch of if statements in front of every single view. That's some additional work to do and another opportunity to introduce bugs.

The good thing is that a model with a state machine under the hood, where the current state is not a public and mutable property (like the request path), can completely eliminate this problem. We don't expose all the possible states, just to later limit the access with if statements. Instead of that, we relay on the fact that there's always only one current node and we have one endpoint like /wizard. Here's the whole express-specific code:

app.get('/wizard', async (req, res) => {
  res.send(await render_wizard(wizard))
})

app.post('/wizard', async (req, res) => {
  await wizard.handle(req)
  res.redirect('/')
})

The "go back" button just goes back

A very simple example of node reuasbility is the "go back" button. What does it do? It goes back. But where? The graph will tell us.

That's the code of the button:

const go_back = view(() => ({

  render() {
    return safeHtml`<input
      type="submit"
      name="${this.pname('back')}"
      value="Back">`
  },

  handle(params) {
    if(params['back']) this.follow("go_back")
  }

}))

When clicked, it follows the go_back arrow. That's it. It doesn't tell where exactly does it go. It just express the intention to go back.

wizard

Here if we click the "back" button we are redirected to the screen where we can enter the name.

wizard

But here clicking the same button will take us to the screen where we can enter the promo code. And there are no ifs for that. The same button may be reused wherever we want. We just connect the arrow to a different node.

We should give state machines a go

It seems that in web development, compared to areas like game development, we don’t utilize the full power of state machines. If you haven’t tried yet, I strongly encourage you to identify state machines hidden in your code. Extracting them into a readable structure often leads to a surprisingly clear, declarative model of our domain.

Further reading

From the author of this blog

  • howlong.app - a timesheet built for freelancers, not against them!