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:
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;
}
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 toloading
on theFETCH
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.
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"
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 thedata-active
attribute. - When the overall state doesn't match any
[data-hide="..."]
state, the element should also have thedata-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;
}
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 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 bedata-active
if one of the states matches the overalldata-state
(e.g.,data-show="idle loading"
)data-hide
dictates that the element should not bedata-active
if one of the states matches the overalldata-state
(e.g.,data-hide="success error"
)data-active
is dynamically added to the abovedata-show
anddata-hide
elements when they are "matched" by the currentdata-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 theon
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:- calls
transition(...)
to determine the next state - sets the
currentState
to that next state - executes side effects (sets the proper data-attributes, in this case).
- calls
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.
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. 🚀
this is a wonderful blogpost! definitely changed the way i css for these things forever. also TIL about the space-separated attribute selector.