Predictable programming 3 - using XState
In the last two posts (1, 2) in this series, I explained why I want Typescript to be more predictable, and some of the most important guidelines I follow to make that happen. Here, I want to dive into a specific library that has helped me significantly improve the predictability of my React components' behaviour: XState.
Why state machines?
There's a pretty common issue that I've seen with React components, where the usage of many different useEffect
, useCallback
, useState
, etc. combined makes for a very unpredictable component: it's very much not obvious what should happen as a result of pressing any button, or any inputs changing. This makes React components very hard to test.
Now one way to resolve this is to be very strict with yourself about decomposing the behavior into separate, bespoke hooks that you can individually test. However, this adds a lot of cognitive overhead and often many refactors.
A state machine can clarify this process by restricting both the states that a component can be in, as well as the ways that it can get to that state. This makes it easier to develop, as there is less cognitive overhead of keeping in mind all the possible hooks that could affect your changes. Instead, you simple modify the state graph at the relevant part and you don't worry about the rest of the states.
XState 101
A state machine[1] is a simple chart of different states that the machine can be in, and the transitions between those states. Complexer machines will have "submachines" within states. It's pretty straightforward, and the XState docs do a better job of explaining it.
Here's a simple example XState machine:
const machine = createMachine({
tsTypes: {},
schema: {} as {
},
id: 'trafficLight',
initial: 'green',
states: {
green: {},
},
});
Predictable XState?
While state machines by their very nature are more predictable than the spaghetti-logic of hooks, there are many ways to make them even stricter. It's important that we make it hard for ourselves to mess up our apps.
The first thing is to strongly type your XState definitions. Using Typescript with XState's typegen is not perfect, but it does a lot to ensure your machines adhere to a certain basic set of rules.
State machines manage to make even complex logic look a lot simpler. It's still important to break up your machines into smaller machines. You'll find yourself building a lot of smaller substates that can split out. I recommend to write your full state graph in one go, but take a moment after to refactor out the submachines.
Rendering out your state machine and running through it in Stately's simulator is also an excellent way of testing your machine has the right states and transitions.
Testing machines
It's important to test your machines. To ensure testable machines, I've resorted to injecting external effects through the machine's context. This way, I can test the machine in isolation by providing mocks in the context. Looks a bit like this:
const fetchMachine = createMachine({
id: 'fetch',
initial: 'idle',
context: {
fetchService: null
},
states: {
idle: {
on: {
FETCH: 'loading'
}
},
loading: {
invoke: {
src: (context) => context.fetchService(),
onDone: {
target: 'success'
}
}
},
success: {
type: 'final'
}
}
});
test('fetch machine should reach "success" state', done => {
// Mock API service
const mockFetchService = () => Promise.resolve();
// Create a service using the machine with the mock fetch service
const service = interpret(fetchMachine.withContext({
fetchService: mockFetchService
}));
// Listen to state transitions
service.onTransition(state => {
if (state.matches('success')) {
done();
}
});
// Start the service and send the FETCH event
service.start();
service.send('FETCH');
});
Beyond XState
XState v5 is also coming soon and promises to bring a bunch of improvements. I'm excited to try out the new stuff.
Additionally, I feel like there's a possibility of a mostly Typescript state graph library. XState feels very heavy to use still, especially when using its type generation and all its various features. I believe there's a possibility to simplify that and to rely mostly on Typescript for enforcing the state machine properties.
Actually a state graph. ↩︎