Skip to content

Instantly share code, notes, and snippets.

@davidkpiano
Last active June 15, 2023 15:26
Show Gist options
  • Star 32 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save davidkpiano/e715b59bef817d2146164add26a134b0 to your computer and use it in GitHub Desktop.
Save davidkpiano/e715b59bef817d2146164add26a134b0 to your computer and use it in GitHub Desktop.
Article for creating CSS State Machines

As the number of different possible states and transitions between states in a user interface grows, managing styles and animations can quickly become complicated. Even a simple login form has many different "user flows":

https://codepen.io/davidkpiano/pen/WKvPBP

State machines are an excellent pattern for managing state transitions in user interfaces in an intuitive, declarative way. We've been using them a lot on the Keyframers as a way to simplify otherwise complex animations and user flows, like the one above.

So, what is a state machine? Sounds technical, right? It’s actually more simple and intuitive than you might think. (Don’t look at Wikipedia just yet… trust me.)

Let’s approach this from an animation perspective. Suppose you’re creating a loading animation, which can be in only one of four states at any given time:

  • idle (not loading yet)
  • loading
  • failure
  • success

This makes sense - it should be impossible for your animation to be in both the “loading” and “success” states at the same time. But, it’s also important to consider how these states transition to each other:

Simple state machine

Each arrow shows us how one state transitions to another state via events, and how some state transitions should be impossible (that is, you can’t go from the success state to the failure state). Each one of those arrows is an animation that you can implement, or more practically, a transition. If you’re wondering where the term “CSS transitions” comes from, it’s for describing how one visual “state” in CSS transitions to another visual “state.”

In other words, if you’re using CSS transitions, you’ve been using state machines all along and you didn’t even realize it! However, you were probably toggling between different "states" by adding and removing classes:

.button {
  /* ... button styles ... */
  transition: all 0.3s ease-in-out;
}

.button.is-loading {
  opacity: 0.5;
}

.button.is-loaded {
  opacity: 1;
  background-color: green;
}

This may work fine, but you have to make sure that the is-loading class is removed and the is-loaded class is added, because it's all too possible to have a .button.is-loading.is-loaded. This can lead to unintended side-effects.

A better pattern for this is using data-attributes. They're useful because they represent a single value. When a part of your UI can only be in one state at a time (such as loading or success or error), updating a data-attribute is much more straightforward:

const elButton = document.querySelector('.button');

// set to loading
elButton.dataset.state = 'loading';

// set to success
elButton.dataset.state = 'success';

This naturally enforces that there is a single, finite state that your button can be in at any given time. You can use this data-state attribute to represent the different button states:

.button[data-state="loading"] {
  opacity: 0.5;
}

.button[data-state="success"] {
  opacity: 1;
  background-color: green;
}

Finite State Machines

More formally, a finite state machine is made up of five parts:

  • A finite set of states (e.g., idle, loading, success, failure)
  • A finite set of events (e.g., FETCH, ERROR, RESOLVE, RETRY)
  • An initial state (e.g., idle)
  • A set of transitions (e.g., idle transitions to loading on the FETCH event)
  • Final states

And it has a couple rules:

  • A finite state machine can only be in one state at any given time
  • All transitions must be deterministic, meaning for any given state and event, it must always go to the same predefined next state. No surprises!

Now let's look at how we can represent finite states in HTML and CSS.

Contextual State

Sometimes, you'll need to style other UI components based on what state the app (or some parent component) is in. "Read-only" data-attributes can also be used for this, such as data-show:

.button[data-state="loading"] .text[data-show="loading"] {
  display: inline-block;
}

.button[data-state="loading"] .text[data-show]:not([data-show="loading"]) {
  display: none;
}

This is a way to signify that certain UI elements should only be shown in certain states. Then, it's just a matter of adding [data-show="..."] to the respective elements that should be shown. If you want to handle a component being shown for multiple states, you can use the space-separated attribute selector:

<button class="button" data-state="idle">
  <!-- Show download icon while in idle or loading states -->
  <span class="icon" data-show="idle loading"></span>
  <span class="text" data-show="idle">Download</span>
  <span class="text" data-show="loading">Downloading...</span>
  <span class="text" data-show="success">Done!</span>
</button>
/* ... */
.button[data-state="loading"] [data-show~="loading"] {
  display: inline-block;
}

The data-state attribute can be modified using JavaScript:

const elButton = document.querySelector('.button');

function setButtonState(state) {
  // set the data-state attribute on the button
  elButton.dataset.state = state;
}

setButtonState('loading');
// the button's data-state attribute is now "loading"

Dynamic Data-Attribute Styles

As your app grows, adding all of these data-attribute rules can make your stylesheet get bigger and harder to maintain, since you have to maintain the different states in both the client JavaScript files and in the stylesheet. It can also make specificity complicated since each class and data-attribute selector adds to the specificity weight. To mitigate this, we can instead use a dynamic data-active attribute that follows these two rules:

  • When the overall state matches a [data-show="..."] state, the element should have the data-active attribute.
  • When the overall state doesn't match any [data-hide="..."] state, the element should also have the data-active attribute.

Here's how this can be implemented in JavaScript:

const elButton = document.querySelector('.button');

function setButtonState(state) {
  // change data-state attribute
  elButton.dataset.state = state;

  // remove any active data-attributes
  document.querySelectorAll(`[data-active]`).forEach(el => {
    delete el.dataset.active;
  });

  // add active data-attributes to proper elements
  document.querySelectorAll(`[data-show~="${state}"], [data-hide]:not([data-hide~="${state}"])`)
    .forEach(el => {
      el.dataset.active = true;
    });
}

// set button state to 'loading'
setButtonState('loading');

Now, our above show/hide styles can be simplified:

.text[data-active] {
  display: inline-block;
}

.text:not([data-active]) {
  display: none;
}

Declaratively Visualizing States

So far, so good. However, we want to prevent function calls to change state littered throughout our UI business logic. We can create a state machine transition function that contains the logic for what the next state should be given the current state and event, and returns that next state. With a switch-case block, here's how that might look:

// ...

function transitionButton(currentState, event) {
  switch (currentState) {
    case 'idle':
      switch (event) {
        case 'FETCH':
          return 'loading';
        default:
          return currentState;
      }
    case 'loading':
      switch (event) {
        case 'ERROR':
          return 'failure';
        case 'RESOLVE':
          return 'success';
        default:
          return currentState;
      }
    case 'failure':
      switch (event) {
        case 'RETRY':
          return 'loading';
        default:
          return currentState;
      }
    case 'success':
      default:
        return currentState;
  }
}

let currentState = 'idle';

function send(event) {
  currentState = transitionButton(currentState, event);

  // change data-attributes
  setButtonState(currentState);
}

send('FETCH');
// => button state is now 'loading'

The switch-case block codifies the transitions between states based on events. We can simplify this by using objects instead:

// ...

const buttonMachine = {
  initial: 'idle',
  states: {
    idle: {
      on: {
        FETCH: 'loading'
      }
    },
    loading: {
      on: {
        ERROR: 'failure',
        RESOLVE: 'success'
      }
    },
    failure: {
      on: {
        RETRY: 'loading'
      }
    },
    success: {}
  }
};

let currentState = buttonMachine.initial;

function transitionButton(currentState, event) {
  return buttonMachine
    .states[currentState]
    .on[event] || currentState; // fallback to current state
}

// ...
// use the same send() function

Not only does this look cleaner than the switch-case code block, but it is also JSON-serializable, and we can declaratively iterate over the states and events. This allows us to copy-paste the buttonMachine definition code into a visualization tool, like xviz:

The state machine we created, visualized

Conclusion

The state machine pattern makes it much simpler to handle state transitions in your app, and also makes it cleaner to apply transition styles in your CSS. To summarize, we introduced the following data-attributes:

  • data-state represents the finite state for the component (e.g., data-state="loading")
  • data-show dictates that the element should be data-active if one of the states matches the overall data-state (e.g., data-show="idle loading")
  • data-hide dictates that the element should not be data-active if one of the states matches the overall data-state (e.g., data-hide="success error")
  • data-active is dynamically added to the above data-show and data-hide elements when they are "matched" by the current data-state.

And the following code patterns:

  • Defining a machine definition as a JavaScript object with the following properties:
    • initial - the initial state of the machine (e.g., "idle")
    • states - a mapping of states to "transition objects" with the on property:
      • on - a mapping of events to next states (e.g., FETCH: "loading")
  • Creating a transition(currentState, event) function that returns the next state by looking it up from the above machine definition
  • Creating a send(event) function that:
    1. calls transition(...) to determine the next state
    2. sets the currentState to that next state
    3. executes side effects (sets the proper data-attributes, in this case).

As a bonus, we're able to visualize our app's behavior from that machine definition! We can also manually test each state by calling setButtonState(...) to the desired state, which will set the proper data-attributes and allow us to develop and debug our components in specific states. This eliminates the frustration of having to "go through the flow" in order to get our app to the proper state.

Going further

If you want to dive deeper into state machines (and their scalable companion, "statecharts"), check out the below resources:

  • xstate is a library I created that facilitates the creation and execution of state machines and statecharts, with support for nested/parallel states, actions, guards, and more. By reading this article, you already know how to use it:
import { Machine } from 'xstate';

const buttonMachine = Machine({
  // the same buttonMachine object from earlier
});

let currentState = buttonMachine.initialState;
// => 'idle'

function send(event) {
  currentState = buttonMachine.transition(currentState, event);

  // change data-attributes
  setButtonState(currentState);
}

send('FETCH');
// => button state is now 'loading'
  • The World of Statecharts is a fantastic resource by Erik Mogensen that thoroughly explains statecharts and how it's applicable to user interfaces
  • Spectrum Statecharts community is full of developers who are helpful, passionate, and eager to learn and use state machines and statecharts
  • Learn State Machines is a course that teaches you the fundamental concepts of statecharts through example - by building an Instagram clone and more!
  • React-Automata is a library by Michele Bertoli that uses xstate and allows you use statecharts in React, with many benefits, including automatically generated snapshot tests!

And finally, I'm working on an interactive statechart visualizer, editor, generative testing and analysis tool for easily creating statecharts for user interfaces. For more info, and to be notified when the beta releases, visit uistates.com. 🚀

@swyxio
Copy link

swyxio commented May 2, 2019

this is a wonderful blogpost! definitely changed the way i css for these things forever. also TIL about the space-separated attribute selector.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment