Implementing component state as a combination of booleans may seem like the easiest way to do it, but let's do something different.
Cover by Namroud Gorguis on Unsplash
This article is framework- and language- agnostic. Code examples presented are written in a generic form.
Consider a music player
That can play, pause, and stop. Developers are often tempted to represent
each state in a separate boolean:
const isStopped = createState(true)
const isPlaying = createState(false)
const isPaused = createState(false)
If you think about this for a moment, each of those boolean states can be either true or false. Counting all possibilities yields 8 possible state variations, when our component only has 3 actual states. Which means we have 5 impossible states in our tiny component.
Impossible states are states that the component is never meant to be in, usually indicating a logic error. The music player can't be playing and stopped at the same time. It also can't be paused and playing at the same time. And so on.
Guard statements usually accompany boolean states for this reason:
if (isStopped && !isPlaying && !isPaused) {
// display stopped UI
} else if (!isStopped && isPlaying && !isPaused) {
// display playing UI
} else if (!isStopped && !isPlaying && isPaused) {
// display paused UI
}
And state updates turn into a repetitive set of instructions:
// To play
setIsPlaying(true)
setIsPaused(false)
setIsStopped(false)
// To stop
setIsPlaying(false)
setIsPaused(false)
setIsStopped(true)
Each addition and modification later to the component needs to respect these 3 valid states, and to guard against those 5 impossible states.
Hello, state machines!
Every program can be simplified into a state machine. A state machine is a mathematical model of computation, an abstract machine that can be in exactly one of a finite number of states at any given time.
It has a list of transitions between its defined states, and may execute effects as a result of a transition.
If we convert our media player states into a state machine we end up with a machine containing exactly 3 states (stopped, playing, and paused), and 5 transitions.
Now we can represent our simple machine in a single state that can be anything, from a Union Type to an Enum:
type State = 'stopped' | 'playing' | 'paused'
enum State {
STOPPED,
PLAYING,
PAUSED
}
Now state updates can be a single, consistent instruction:
setState('stopped')
// or
setState(State.STOPPED)
With this approach we completely eliminate impossible states, make our state easier to control, and improve the component's readability.
What about effects?
An effect is anything secondary to the component's functionality, like loading the track, submitting a form's data, etc. An action.
Let's consider forms. A form is usually found in one of four states: idle, submitting, success, and error. If we use boolean states we end up with 4 booleans, 16 possible combinations, and 12 impossible states.
Instead, let's make it a state machine too!
The code behind this machine can be as simple as another method on the component:
enum State {
IDLE /* default state */,
SUBMITTING,
ERROR,
SUCCESS
}
const submit = (formData: FormData) => {
setState(State.SUBMITTING)
postFormUtility(formData)
.then(() => {
setState(State.SUCCESS)
})
.catch(() => {
setState(State.ERROR)
})
}
The exception
Obviously there are cases where a component may truly have only 2 states, therefore using a boolean for it works perfectly. Examples of this are modals to control their visibility, buttons to indicate a11y activation, etc.
const isVisible = createState<boolean>(false)
const toggle = () => {
setState(!isVisible)
}
The problem starts to form when you introduce multiple booleans to represent variations of the state.
I still need booleans!
You can derive booleans from your state. Control your component through a single state machine variable, but derive a hundred booleans from it if you want.
Using the form example:
enum State {
IDLE /* default state */,
SUBMITTING,
ERROR,
SUCCESS
}
const state = createState(State.IDLE)
const isSubmitting = state === State.SUBMITTING
const hasError = state === State.ERROR
const isSuccessful = state === State.SUCCESS
Wrap up
Thinking of components as state machines has helped me simplify a lot of codebases. It's effect on the overall accessibility of a codebase is truly immense. Try it and tell me what you think! 👀
Thanks for reading! You can follow me on Twitter, or read more of my content on my blog!