Everybody has regrets. As developers, I think many of us regret a few of our technical decisions. One of my biggest regrets is the approach I picked for building the daily quest widget when I worked at Wargaming Prague. (You can read more about this here)
The daily quest widget was a highly animated React component. It had several entrance, emphasis and exit animations where several elements had to animate in sync. Until that point, I had mostly worked on React apps for the web where most components were static with little interactivity. The built-in React hooks had been fine for managing the state of these components so I thought that it would also be OK for this widget. I was wrong.
After using only the built-in React hooks (useState
and useEffect
) to build the widget, I discovered that they were horrible for managing complex state. The daily quest widget had several visual states where the state had to change when an event was received from a timer or another React component. I got it to work but it was a huge mess. Even the slightest change to the component or its behavior could make it go berserk.
I didn't want to leave that mess in the component. I remembered a talk by David Khourshid about state machines and I wondered whether they could tame this. Sadly, I didn't have enough time to test this theory on the daily quest widget. However, advocates of state machines say that state machines are not just for complex state and that using state machines leads to code that is bug-free and easier to maintain. Let's see if this is true by refactoring Timo to use XState, a state machine and state chart library for JS.
Since state machines can contain state operations within themselves, I am also going to challenge myself to not use the useState
and useEffect
hooks during this refactor.
What are state machines and state charts?
A state machine is a conceptual model that allows us to describe the behavior of a machine. It consists of the different "states" a machine can be in and how it can "transition" between them.
A state chart is another conceptual model that builds on top of state machines. It adds extra concepts such as child states and parallel states to make it easier to describe more complex behavior.
XState allows us to describe state machines and state charts using Javascript. These models can then be "run" and when events are sent to them, they will change state depending on how it is set up. We can then read this state and use it to implement the behavior of an app.
import { createMachine, createActor } from 'xstate';
export const trafficLightMachine = createMachine({
id: 'trafficLight',
initial: 'red', // Initial state of the machine
states: {
red: { // A state in the machine
on: { // Transitions to run when certain events are received
turnGreen: { // A transition that runs when the turnGreen event is received
target: 'green' // The state to transition to
}
}
},
green: { // Another state in the machine
on: {
turnRed: {
target: 'red'
}
}
}
}
});
// XState refers to running instances of state machines and state charts as actors
// To run a state machine, we need to turn it into an actor.
const trafficLightActor = createActor(trafficLightMachine);
// We can subscribe to an actor to be notified whenever its state changes
trafficLightActor.subscribe((snapshot) => { // snapshot contains the current state of the actor and other metadata
// The current state of the actor is in snapshot.value
console.log(snapshot.value);
})
// Start running the actor.
// It will go to the initial state and transition to other states from there
trafficLightActor.start();
// Send an event to the actor after 2 seconds
window.setTimeout(() => {
trafficLightActor.send({ type: 'turnGreen' })
}, 2000)
Refactoring Timo to use XState
When I started, I had no idea how Timo's logic could be expressed as a state chart. Timo is not a big app but thinking about all its screens and actions at once was very overwhelming. But to kick things off, I decided to focus only on the user authentication.
The React package for XState provides hooks for sending events to a state machine and reading its state. XState refers to both state charts and state machines as machines so I will do the same in this post.
import { useActor, useSelector } from '@xstate/react';
import { trafficLightMachine } from './trafficLightMachine';
const TrafficLight = () => {
// useActor takes in a definition of a machine and constructs an actor under the hood
// snapshot updates every time the state of the actor changes
// send can be used to send events to the actor
const [snapshot, send] = useActor(trafficLightMachine);
return (
<div>
<div>{snapshot.value}</div>
<button onClick={() => { send({ type: 'turnGreen' })}}>
Turn green
</button>
</div>
);
};
const trafficLightActor = createActor(trafficLightMachine);
trafficLightActor.start();
const OptimizedTrafficLight = () => {
// useSelector takes the reference to an actor that has already been created
// and a selector function
// It only updates when that value returned by the selector function changes
const value = useSelector(trafficLightActor, (snapshot) => snapshot.value);
return (
<div>
<div>{value}</div>
<button onClick={() => { trafficLightActor.send({ type: 'turnGreen' })}}>
Turn green
</button>
</div>
);
};
Creating a machine to describe the behavior of the user authentication was relatively straightforward. Xstate has helpers that can automatically construct machines for working with promises and callbacks. Moreover, XState machines can "invoke" or "spawn" other machines and then communicate with them. So in this case, we can invoke the generated machine to use it within our own user authentication machine.
import { setup, fromPromise, assign } from 'xstate';
import { getUser } from '@timo/common/api';
// setup allows the defaults for configurable options to be set
// These options can later be overriden by calling userAuthMachine.provide({})
const userAuthMachine = setup({
actors: { // Specify machines that can later be invoked or spawned.
getUser: fromPromise(getUser) // Create a machine to run the getUser promise
}
}).createMachine({
initial: 'unknown',
context: { // Context can contain any data. It can be read and written by the machine and read from the snapshot.
userData: null
},
states: {
'unknown': {
// Invoke a machine when this state is entered.
// Invoked machines only run while this state is active.
invoke: {
src: 'getUser',
onDone: { // Transition when the promise resolves
target: 'authenticated',
actions: [ // Action to run when the transition occurs
assign({ // Action to write data to the context
userData: ({ event }) => ({ // Dynamic syntax for overriding a property in context
id: event.output.id, // fromPromise machine exposes the result of the promise in output
username: event.output.username,
avatar_character: event.output.avatar_character,
avatar_background: event.output.avatar_background
})
})
]
},
onError: { // Transition when the promise throws an error
target: 'unauthenticated'
}
}
},
'authenticated': {
on : {
'unauthenticate': { // Transition when the unauthenticate event is received
target: 'unauthenticated',
actions: [
assign({ userData: null })
]
}
}
},
'unauthenticated': {
on : {
'authenticate': { // Transition when the authenticate event is received
target: 'authenticated',
actions: [
assign({
userData:({ event }) => ({
id: event.params.id, // event.params is custom parameters passed when sending the authenticate event
username: event.params.username,
avatar_character: event.params.avatar_character,
avatar_background: event.params.avatar_background
})
})
]
}
}
}
}
});
With the user authentication machine out of the way, I had to start refactoring the other screens to use machines. This was where the struggle began.
While XState machines could have several layers of child states, I did not want all of Timo's logic to live in a single machine. I wanted to split the logic into several machines so that each one could handle a specific entity or screen. I felt that doing this would lead to simpler machines that were easier to understand. But figuring out how to do this took several attempts and several days. The XState docs do a decent job of explaining the different state machine and state chart concepts and how they work in XState. However, it sorely lacks a guide on how these concepts can be used to describe the behavior of a common web app.
The thing I struggled with the most was figuring out how to communicate with "invoked" or "spawned" machines from React. The obvious way was to define events in the "parent" machine to send events to the child machines. But I knew there must be a better way.
import { setup, sendTo } from 'xstate';
// A machine that handles the logic for the login screen
import loginMachine from './loginMachine'
const parentMachine = setup({
actors: {
login: loginMachine
// machines for the other screens
}
}).createMachine({
initial: 'unknown',
states: {
// unknown and unauthenticated states
'unauthenticated': {
invoke: {
// This id needs to be specified when using sendTo to send events
id: 'login',
src: 'login'
}
on: {
'login': { // Event that will be sent from a React component
actions: [
sendTo( // Action to send events to invoked or spawned machines
'login', // Send event to machine with id 'login'
({ event }) => ({ // Dynamically construct the event object
type: 'login', // This event should be handled by the loginMachine
params: { // Pass along the parameters received
username: event.params.username,
password: event.params.password
}
})
)
]
// Create similar transitions for every single action in the app
// while having the actual logic in the screen specific machine?
// Nope ❌
}
}
}
}
})
After a lot of poking around, I found that references to child machines could be found in the children
property in the parent machine's snapshot. These references could be used by XState's React hooks and that was exactly what I needed. For added convenience, I wrote a few custom hooks so I don't have to repeat myself (DRY).
// Original: https://github.com/bashlk/timo/tree/main/packages/xstate/src/hooks
import { useSelector } from '@xstate/react';
import { useContext } from 'react';
// The actor instance of the parent/root machine is stored as the context value
import { MachineContext } from '../context/MachineContext';
// I called my parent machine "rootMachine"
export const useRootMachine = () => {
return useContext(MachineContext);
};
export const useRootMachineState = (selector) => {
const rootMachine = useRootMachine();
return useSelector(rootMachine, selector);
};
export const useChildMachine = (childId) => {
const rootMachine = useRootMachine();
return useSelector(rootMachine, (state) => state.children[childId]);
};
export const useChildMachineState = (childId, selector) => {
const machine = useChildMachine(childId);
return useSelector(machine, selector);
};
With this figured out, I could finally get back to refactoring the screens. Initially, I found it very difficult to imagine how the behavior of a screen could be expressed as a state machine. After using Redux Toolkit and Tanstack query, I have gotten used to thinking that API operations have 4 states (not started, loading, success, error). Manually implementing 4 states for every API operation felt very tedious but then I realized that given Timo's design, I could get away with having only two states per API operation (idle, loading).
// Original: https://github.com/bashlk/timo/blob/main/packages/xstate/src/machines/login.js
import { setup, fromPromise, assign, sendTo } from 'xstate';
import { login } from '@timo/common/api';
export const loginMachine = setup({
actors: {
// input is data that can be provided to a state machine as it started
// Adapt it to fit the parameter shape of the shared API functions
login: fromPromise(async ({ input }) => login(input))
},
}).createMachine({
id: 'login',
context: {
statusMessage: null
},
initial: 'idle',
states: {
'idle': {
on: {
'login': {
target: 'logging-in',
actions: assign({
statusMessage: 'Logging in...'
})
},
}
},
'logging-in': {
invoke: {
src: 'login',
// Adapt data in the event object to be sent as input to the invoked login machine
input: ({ event }) => ({ username: event.username, password: event.password }),
onDone: {
target: 'idle',
actions: [
// Send event to root machine so that it can change its authentication status
sendTo(
// The root machine is assigned a systemId when it is turned into an actor
// This allows it to be accessed like this
({ system }) => system.get('root'),
// The userData is returned from the login call
// The fromPromise helper stores the return value in output
// Pass it as an event parameter to the root machine
({ event }) => ({ type: 'authenticate', params: event.output })
)
]
},
onError: {
target: 'idle',
actions: assign({
statusMessage: ({ event }) => event.error.message
})
}
}
}
}
});
Once I found this pattern, I could refactor the other screens much faster. To achieve my goal of removing all the useState
and useEffect
hooks, I also reworked the routing to use a state machine. The result was a fully functioning version of Timo with 0 useState
hooks and 0 useEffect
hooks. You can find the code here.
Was it worth it?
A common first impression about state machines and state charts is that they are way too overkill for "normal" use cases. I had the same impression.
When I started refactoring Timo to use XState, I felt like this was true. While it allowed the state and side effect logic to be removed from the React components, the machine definitions that replaced them were much more verbose. The steep learning curve of expressing behavior as state machines and using multiple state machines also didn't help.
Lines of code in the plain React version of Timo
cloc packages/react/src/routes
-------------------------------------------------------------------------------
Language files blank comment code
-------------------------------------------------------------------------------
JSX 6 54 2 497
CSS 4 23 0 127
-------------------------------------------------------------------------------
SUM: 10 77 2 624
-------------------------------------------------------------------------------
Lines of code in the Xstate version of Timo
cloc packages/xstate/src/routes
-------------------------------------------------------------------------------
Language files blank comment code
-------------------------------------------------------------------------------
JSX 6 34 0 378(-119 -24%)
CSS 4 23 0 128
-------------------------------------------------------------------------------
SUM: 10 57 0 506
-------------------------------------------------------------------------------
cloc packages/xstate/src/machines
-------------------------------------------------------------------------------
Language files blank comment code
-------------------------------------------------------------------------------
JavaScript 7 19 2 714(6x)
-------------------------------------------------------------------------------
SUM: 7 19 2 714
-------------------------------------------------------------------------------
But as I kept refactoring, my opinion started to shift. Many components got much nicer once they were rewritten to use state machines. The mess of useEffect
and useState
hooks in some components were replaced with state machine logic that was much easier to understand.
// Timer component in the plain React version of Timo
// https://github.com/bashlk/timo/blob/main/packages/common/components/Timer/Timer.jsx
import { useEffect, useState } from 'react';
export const Timer = ({ value, active, onPaused }) => {
// The ticking timer value is stored here so that only this component
// updates as the timer ticks
const [currentValue, setCurrentValue] = useState(value);
useEffect(() => {
let interval;
if (active) {
interval = setInterval(() => {
setCurrentValue((val) => val + 1);
}, 1000);
}
return () => clearInterval(interval);
}, [active, value]);
useEffect(() => {
if (!active) {
// The timer value is also present in the parent component (NewEntry)
// since that is used when the time entry is saved
onPaused(currentValue);
}
}, [active, currentValue, onPaused]);
useEffect(() => {
// If the timer is reset, the ticking timer value should also reset
setCurrentValue(value);
}, [value]);
// Format duration to HH:MM:SS
const formattedValue = new Date(currentValue * 1000).toISOString().slice(11, 19);
return (
<div>{formattedValue}</div>
);
};
// Timer component in the XState version of Timo
// https://github.com/bashlk/timo/blob/main/packages/xstate/src/components/Timer.jsx
import useChildMachineState from '../hooks/useChildMachineState';
export const Timer = () => {
const timerValue = useChildMachineState('newEntry', state => state.context.timerValue);
// Format duration to HH:MM:SS
const formattedValue = new Date(timerValue * 1000).toISOString().slice(11, 19);
return (
<div>{formattedValue}</div>
);
};
// Trimmed version of the newEntry machine
// Original: https://github.com/bashlk/timo/blob/main/packages/xstate/src/machines/newEntry.js
import { setup, fromCallback, assign, sendTo } from 'xstate';
const newEntryMachine = setup({
actors: {
// fromCallback allows us to construct a machine
// that can send / receive events
timer: fromCallback(({ sendBack, receive }) => {
const interval = setInterval(() => {
sendBack({
type: 'tick'
});
}, 1000);
receive((event) => {
if (event.type === 'stop') {
clearInterval(interval);
}
});
})
}
}).createMachine({
id: 'newEntry',
initial: 'idle',
context: {
timerValue: 0,
},
states: {
'idle': {
on: {
'start': {
target: 'active'
}
}
},
'active': {
invoke: {
// Invoke the timer machine when this state is entered
// It will start sending tick events
// The machine will be stopped when this state is left
src: 'timer',
id: 'timer'
},
on: {
'tick': {
actions: assign({
timerValue: ({ context }) => context.timerValue + 1
})
},
'pause': {
target: 'paused',
actions: [
// Send stop event so that the interval is not left hanging
sendTo('timer', {
type: 'stop'
})
]
},
},
},
'paused': {
on: {
'resume': {
target: 'active'
}
}
}
}
});
When I finished refactoring I felt more confident about this version of Timo than any of the previous ones. Using state machines forced me to think about and handle all the states that each screen could be in. I felt reassured that a user would only see the states defined in the machines and nothing else.
So here are my takeaways about XState.
- I am now convinced state machines are a much nicer way to handle complex state. In fact, I would rather take a hit to the bundle size by adopting state machines than face the hell of
useEffect
heavy components again. - Using state machines and state charts for an entire app is hard. First, there is the extra verbosity of state machines. Next, there is the steep learning curve for thinking in state machines. Many times I couldn't work it out in my head and I had to draw it out on paper.
- But after spending that effort, I had more confidence in the app's stability. The chance of a user seeing a broken state was significantly reduced.
- I also felt more confident about changing the app. The visuals of the app and its behavior were now separated and making changes to each felt more manageable.
This brings me to a dilemma. XState's steep learning curve and the extra effort required to use it makes it hard for me to recommend it. But the added confidence and ease of mind it brings is really nice. Despite the struggle, it is the best I have felt about working with React in a long time. So if you have the time and if you would like a challenge, I would recommend that you give XState and state charts a try. I know that I will seriously consider using it in the next app I build.
With that, I would like to bring this series about state management libraries to a close. In the next monthly post, I will summarize my thoughts and findings on all of the libraries that we have taken a look so far before kicking off the next series.