Whenever we make a technical decision in a software implementation project, we’re making a trade-off. As software engineers, we are used to leaning on our knowledge and experience to help us fight uncertainty in these kinds of decisions. Of course, learning from other people’s experience can help as well and that’s what this blog post is all about. In this article we’ll share what you should know about state machines before deciding to use them in your next project.
New Mental Model
Picking the right tool for the job can take us a long way, but that works the other way as well. A tool that is not a good fit for your team can introduce unnecessary friction and cause long term problems. We found that a good way to evaluate if state machines can help you is by comparing their mental model to Redux.
From a very high level perspective, Redux does this: given the current state and an action, you get the new state of the application. In this case, state includes both the data the application is operating on and the data that describes the current application status. This is the key difference – state machines do not treat those as the same: finite states (like loading) describe the current application status while extended state represents the data the application is operating on.
State machines work on a similar principle: given the current state and an event, we transition to a new state and run effects that allow us to update the extended state. This serves as a powerful tool for codifying business rules: all the possible application states and their relationships are declared upfront so the application can only be in a state you defined. The effects are then used to perform changes to the extended state. Check this article out for more details.
This gives us a lot of control over application state, but it also comes with a cost. In the following section we’ll share some of the implementation challenges you might encounter when implementing state machines.
Who Owns the State?
Previously we mentioned that state machines operate on finite and extended states. While finite state is an integral part of a state machine definition, extended state is optional. So that leaves us with a decision to make – where do we store the data that the application is operating on? Should it be stored and managed by the state machine internally? Or should it be external, with some way of accessing it both from the state machine runtime and our UI components?
Our library of choice for dealing with state machines is XState. XState ships with a powerful API that uses context to store data within the state machine and manage its lifecycle. There are two strong benefits of using this approach:
- Context is part of the state machine definition so it serves as a strong contract for all downstream consumers. This works especially well if types are used to specify the context interface.
- Context is safe from accidental mutations: it can only be modified from effects that happen on state transitions.
This approach works well for a lot of cases, but it can be challenging to manage at scale.
Problems with Scaling
As requirements get more complex, a state machine definition can grow in two ways: by introducing new states and transitions or by making the extended state grow. Let’s talk about scaling states and transitions first.
The easiest way to model and visualize behavior is to keep the state machine flat – all possible states are mutually exclusive in that case. This can eventually create a maintenance problem that’s so bad that it has a name – combinatorial explosion. You can recognise that is happening if, when adding a new state, you end up modifying multiple existing states and the transitions between them – that means it’s time for a new approach.
XState gives us a strong tool to fight this kind of complexity – statecharts. This is something we use extensively since it’s a natural way to break complex behaviours down. We prefer thinking about statecharts as bits of reusable behaviour you can embed in your higher level state machines.
The other scaling problem manifests itself when the extended state grows so much that unrelated data updates cause frequent re-rendering. If you keep all the data in XState context, that means that all the components that are consuming the context will render again on every change. This is especially painful if the data model is nested – components will render again even if the specific subset of context they are interested in hasn’t changed. If this affects performance, it can be mitigated by careful use of React.memo. We also leveraged selector functions to make sure our derived data is calculated only when the underlying raw data changes.
If you have a complex data model, this might be an issue and an alternative reactivity solution would be preferable. Dedicated reactivity solutions can help a lot in that case, so you might opt for something like MobX alongside Xstate. Leveraging its reactivity model would help making sure that components render only when relevant data changes.
So far we touched on the higher level benefits and challenges of using state machines to control application state. In addition to that, here’s a list of pros and cons coming from the hands-on experience with state machines in production, pros first:
- Business rules and user interactions can be described in a declarative way, switching focus from coding to business requirements.
- The Visualization tool can be used to turn state machine configurations to statechart diagrams, which helps communication between developers and product team members.
- Debugging is easier: you could see a history of states and events that triggered the transitions.
- Components can be leaner as a side effect of switching to a declarative coding style.
And then the cons:
- Modelling real world problems with state machines takes time and practice to do well. All team members needed some time to learn the approach and get comfortable with it.
- XState API has a large surface area and the documentation can be overwhelming for beginners.
- Simple coding errors in state machine configuration often led to bugs that were hard to track down
- Since there are no clear recipes on how to reduce complexity when your model grows, you have to do it yourself and it can be a bit painful
- While improving readability on a higher level, some low level coding tasks get a bit more complex
If you’re thinking about using XState or explicit state machines in general, I would recommend experimenting on a smaller scale first. Regardless of how sound the concept is, putting it into practice is not trivial and it takes time and effort to be done the right way. Instead of going all the way and managing the full application state with state machines, my suggestion would be to first try it in scope of a single component. That’s a nice isolated level you can use as a proof of concept without interfering with the rest of the application. If you find value in that, you just might be ready to drive more complex behaviours using state machines.