Untangling your Logic using State Machines

Untangling your Logic using State Machines

Featured on Hashnode

You may find this article useful if:

  • You can read JS / Object oriented languages (Python, C++, C#, Java, etc)
  • You are familiar with writing functions (stackoverflow.com/a/4709224)

Introduction

A few weeks ago, I was working on an application where I had to control a button's visibility. From the moment I started, I knew the following things:

  • My button could be either visible or invisible.
  • If any key was pressed while my button was invisible, it would become visible.
  • When my button became visible, a 3-second timer would start.
  • If the timer expired, the button would then become invisible.
  • if a key was pressed while my button was visible, the timer would restart.

We could explain this logic using the following diagram:

Diagram of the states that were previously described. There's two circles, one for "visible", the other one for "invisible". Both have arrows pointing to each other, representing the transitions that can happen between states.

I decided it was good enough, so I started coding right away. My code looked something like this:

// Code simplified for explanation purposes
function onKeyPress() {
  if(button.visible) {
    restartTimer();
  } else {
    button.visible = true;
  }
}

function restartTimer() {
  if(timer.exists) {
    timer.delete();
  }

  timer = new Timer("3 seconds");
  if(timer.elapsed) {
      button.visible = false;
  }
}

I wasn't really feeling the result. My beautiful button was popping in and out of the screen without much of a transition or animation. I knew the designers in my team wouldn't be happy with my work, so I decided to add some fanciness to my work. I opted for a 1s opacity transition, then got back into coding. I ended with something like this:

// Code simplified for explanation purposes
function onKeyPress() {
  if(button.visible) {
    restartTimer();
  } else {
    // Wait for transition to complete.
    waitTransition("1 second", "opacity=1")
    button.visible = true;
  }
}

function restartTimer() {
  if(timer.exists) {
    timer.delete();
  }

  timer = new Timer("3 seconds");
  if(timer.elapsed) {
    waitTransition("1 second", "opacity=0")
      button.visible = false;
  }
}

Yet, this introduced a new bug to my code. Can you spot it? Try going back to the code and see if you can find it.

Did you spot it? Don't worry if not! It took me some time to find it. Here's a clue: What would happen if you pressed a key while a transition is happening? Since you pressed a key, the timer should restart and button's opacity should go back to 1.

Where should I add this? I decided to add a new isFadingOut property to my button, so my code now looked like this:

// Code simplified for explanation purposes
function onKeyPress() {
  if(button.isFadingOut) {
    waitTransition("1 second", "opacity=1");
    button.visible = true;
  }
  else if(button.visible) {
    restartTimer();
  } else {
    // Wait for transition to complete.
    waitTransition("1 second", "opacity=1")
    button.visible = true;
  }
}

function restartTimer() {
  if(timer.exists) {
    timer.delete();
  }

  timer = new Timer("3 seconds");
  if(timer.elapsed) {
    // Wait for transition to complete.
    button.isFadingOut = true;
    waitTransition("1 second", "opacity=0")
    button.isFadingOut = false;
      button.visible = false;
  }
}

This ended up creating a new list of bugs, most of them caused by a race condition. This was getting out of hand! Now I had to deal with several timers at the same time. What if I had to add a new fadingIn state? How much would that mess up my code? I decided it was time to change the way I approached the problem.

State Machines save the day.

You may have noticed that the opacity transition created a new element in our diagram:

A new circle has been added to the previous diagram. This new circle represent the "fadingIn" state that can happen when going from visible to invisible.

This diagram represents a State Machine. It is one of the ways how one can be drawn. State machine are a great tool to visualize all the states and transitions in our application. Every circle represents a state, while every arrow is a transition between states. They also help us see all the different inputs needed for a transition between states to happen. All in all, they are a great way to untangle almost any sort of boolean mess

This is all great but, how do I use them?

One of the ways in which we can implement a State Machine is using enumerators. They don't exist in JavaScript natively, but we can simulate one using an object:

const buttonStates = {
  // You could also map these to a number instead of the same string,
  // but this is personal preference as it's easier to debug.
  fadingOut: "fadingOut",
  visible: "visible",
  invisible: "invisible"
};

We can then store the current state of our button in a property:

// start with a default state
button.state = buttonStates.visible;

We'll need to add a new function in charge of transitioning between states:

function changeState(newState) {
  button.state = newState;

  if(newState === buttonStates.visible) {
    clearTransitions();
    waitTransition("1 second", "alpha=1");
    restartTimer();
  }

  if(newState === buttonStates.fadingOut) {
    waitTransition("1 second", "alpha=0")
  }
}

Finally, we need to adapt both our previous functions to take our new state into account:

function onKeyPress(){
  if(button.state === buttonStates.visible) {
    restartTimer();
  }

  if(button.state === buttonStates.invisible) {
    changeState(buttonStates.visible) 
  }

  if(button.state === buttonStates.fadingOut) {
      changeState(buttonStates.visible)
  } 
}

function restartTimer() {
  if(timer.exists) {
    timer.delete();
  }

  timer = new Timer("3 seconds");
  if(timer.elapsed) {
    changeState(buttonStates.fadingOut)
  }
}

This is not only easier to debug, but it also makes it simpler to add new States to our button. As an example, you could add a new fadingIn state by:

  1. Adding it to our enumerator
  2. Adding a new if statement both in changeState and restartTimer.

Once this is done, you may notice this logic won't easily clash with what we previously did. Every state has a different behaviour that is split into its own block.

When do I use them?

As I mentioned, State machines are a great tool for several use cases. They are implemented in day-to-day tools and can be seen in modern libraries such as xstate. However, they shouldn't always be used. There are some cases where a State Machine may even make our logic more complicated. Here's a list of pros and cons I've found while working with them:

Pros:

  • They make apps easier to debug, by separating each state into its own block
  • It's easy to add new states into your application
  • They make your code easier to read.

Cons:

  • They have a learning curve, people who are not familiar with them may find them confusing.
  • Not the best way to implement that on-off button you're working on.

Learn more about State Machines

Using enums and if/else statements is not the only way to create a state machine. This is only one of the approaches you can take to do it. Here's a list of places where you can learn more about them:

Hey! Thank you for reading my article. If you learned something new or enjoyed my daily dev struggles, please follow me on Twitter: @robruizrdevs.

See you soon! :)

Did you find this article valuable?

Support Roberto Ruiz by becoming a sponsor. Any amount is appreciated!