Great state machines

UI engineering involves building various sorts of components with interactivity. These include modals, buttons, login forms, toggles and search inputs, amongst others. What they all have in common is that they handle logic and the ability to show and hide controls or information to the user.

This problem is often modelled with the use of boolean flags. Engineers will create boolean flags to represent if something appears or is loading data from a server.

isModalOpen: false isLoading: false, isError:false

So what's the issue with this? Let’s take a look at the common example of a login form. Where the flow looks like this:

1. Account for a valid email and password

2. Authenticate credentials with a server

3. If the user is valid, then display a logged in page

Typically this would be modelled using boolean flags in the following way:

isLoading, isError, isSuccess

In order to render each of these scenarios we would combine these booleans:

!isLoading && !isError && !isSuccess ? 'idle'   
isLoading && !isError && !isSuccess ? ‘Loading’ : ‘’ 
!isLoading && !isError && isSuccess ? 'Success'  : ''  
!isLoading && isError && !isSuccess ? 'ErrorMessage' : '' 

Implicit states

This would give us loading, error and success states which we can use to render or load those scenarios. The main issue we find is that the states are implicit and there’s a chance it could lead to an impossible state, so it’s up to the engineer to ascertain the correct logic to move forward. With the three boolean flags theres the possibility of eight states! Two for the first, times two for the second and times two for the third. This is known as boolean explosion.

So what happens now if we want to display information to the user about an invalid password? There are other questions that arise when designing a form like this:

  • What happens if the login fails?
  • Where do errors from the form show up?
  • Should we disable the submit button when the form is logging in?
  • Should we validate the username and password before submission?
  • When should we validate the username and password? As they type, when they move to another field or when they click submit?
  • Does the button show a loading spinner when pending?
  • How does the user cancel a logged in request?

How do we account for all these states using booleans?

isLoading: false 
isError:false 
isSuccess:false 
isUserValid:false 
isPasswordValid:false 
isCancelled:false

At this point it becomes onerous to decipher which state the application is in, and which combination of boolean flags to determine which state to expose to the user. What if we want to disable the submit button when the user has clicked submit (as we want to stop multiple attempts for impatient users) but also, what if an error is returned? We need to apply those conditions.

Modelling behaviour with state machines

How do we address this problem? A much more robust approach is to use state machines to model the behaviour of an app or component. State machines, or more specifically finite state machines, describe a pattern where applications can only be in one of a number of finite states at any given time. State machines have been around for decades but often used in embedded systems, hardware, electronics automotive and games development. State machines adhere to the following criteria:

  • Have one initial state
  • A finite number of states
  • A finite number of events
  • And a mapping of state transitions triggered by events

So how do we make the app logic for our login form better? Firstly, we identify the states and make them explicit:

const loginState = { 
IDLE:'IDLE', // not submitted yet 
PENDING:'PENDING', // Submitted and logging in 
SUCCESS:'SUCCESS' // Logged in successfully 
ERROR:'ERROR', // login failed 
}

It's now impossible for the app to be in the loading and success state at the same time. We only allow these discrete states.

We can go one step further with this and use Object.freeze which stops modification, so any other engineers working on the codebase can’t accidentally change them. These events cause the app to change state.

const loginState = Object.freeze({ 
IDLE:'IDLE', // not submitted yet 
PENDING:'PENDING', // Submitted and logging in 
SUCCESS:'SUCCESS' // Logged in successfully 
ERROR:'ERROR', // login failed 
})

If we go back to our button, we now don’t necessarily need to add a disabled attribute as the state machine constrains what events are allowed in the explicit state. In this instance, the ‘SUBMIT’ event is only valid in the ‘IDLE’ state. When we transition to the ‘PENDING’ state we only have ‘RESOLVE’ or ‘REJECT’, which are fired from the promise.

Visualisation

The other added benefit of using state machines is it's easier to visualise this logic. A leading library for managing state machines is XState.

This visualiser can be used alongside your app in development mode. They've also just released a state machine editor to visually create state machines. Being able to onboard to a project and visualise and interact with the logic is a huge win for comprehension. This can be shared amongst members of the project team like QA testers, developers and project owners.

The other benefit of XState is that it's both library and framework agnostic. You can port the logic and use with most frameworks/libraries like React, Vue, Angular and Svelte. This is an extremely powerful paradigm and in fact there is a new UI library, called Zag, that uses this exact principle. State machines are used to manage the component logic which means it can be easily implemented for different frameworks and libraries. Write once, use everywhere. The component interactions are modelled in a framework agnostic way. We provide adapters for JS frameworks like React, Solid, or Vue.

Bugs

So what about bugs? How do state machines help minimise them? Firstly it forces you to think about the explicit states of the app or component up front and model the events needed (including edge-cases) and make it virtually impossible to end up in an impossible state.

Use cases

Great State have used the XState library extensively for complex forms and workflows, application state management and component logic. It has helped us visual the application logic, help onboard new developers the codebase and expose states and events up front. When you consider the benefits of engineering in this way, I think that state machines to build user interfaces will become more and more prevalent over the next few years.

Related articles