Refactoring a monstrosity using XState 5
As the size of my SaaS application grows, so does the maintenance burden. More features mean more code, and as that code is written I learn about better techniques, libraries, and language features.
One of the most important pages allows my users to view open trips, and it's a monstrosity of complexity. I use Vue single file components (SFC) to combine my template, logic, and styling. This file is 1500 lines long, and this is something I wanted to improve.
One of the things that I have to keep track of is a user's authenticated state with an external service, and this has various sub-states concerning notifications and background services. I had been using various flags and observables to determine my state, and it seemed a logical chunk of code to refactor into a finite state machine. Having heard of xState on the JS Party podcast (or was it Syntax), it seemed like a good choice to try.
My feelings are mixed. It took about a week to implement, owning to my inexperience with the library and inexperience with state machines in general. The logic is much easier to understand, and the code is well organized and more maintainable. On the other hand, I had to implement custom workarounds to shore up areas where the library did not work like I thought it would. This, combined with some problems with typescript and disconnected documentation, is making me wonder whether I should deploy it in production or simply leave it as an academic exercise.
Functionality
My state machine logic is fairly straightforward. The one area that contains some complexity is an area where there are multiple states that can be changed independently. In my app, the results of some asynchronous logic would change multiple flags. In xState, there are "parallel" states that allow this kind of behavior.
There were multiple times when working with the library where expected behavior simply did not work. I suspect this is based on my ignorance or implementations. For instance, during a state transition, I want to change a parallel state as well. Using the xState "raise" action, this does not work:
actions: "expireCredentials"
actions: {
expireCredentials:
function () {
console.log('Credentials expired!') // would echo
raise({ type: "CREDENTIAL_EXPIRATION" }); // state not changed
}
}
However, executing the function inline would work.
actions: [raise({ type: "CREDENTIAL_EXPIRATION" })]; // state changed
I could not figure out why, so most of my actions are inline.
Similarly, in an earlier attempt I was using "assign" to change the "context":
actions: [assign({ pollingRateDelayInMS: event.output })]; // this does not work for some reason
Instead, I had to directly mutate the variable, which is explicitly warned against in the docs:
context.pollingRateDelayInMS = event.output;
I suspect this has to do about the designed immutability of "context", but I could not figure it out.
There were enough of these situations that I was unable to implement the state machine that I originally designed. I had to redesign the structure to avoid areas like these.
Documentation
The documentation is really quite good. The examples, in particular, are excellent. They go far beyond simple use cases and into more complex logic.
However, coming into the library blind, I had a lot of time finding basic, fundamental functions. For instance, what if you want to change multiple states with 1 event?
What page is this functionality found?
I wasted a lot of time trying to use events.
// can I send multiple events with a "target" array? No.
on: {
SUBSCRIPTION_EXISTS: {
target: ["activeSubscription", "loggedIn"],
},
},
// can I send multiple events with an "on" array? No.
on: [{
SUBSCRIPTION_EXISTS: {
target: "activeSubscription",
},
}, {
SUBSCRIPTION_EXISTS: {
target: "loggedIn",
}}
]
I finally asked a question in the Github repo (it was answered in like 10 minutes by the maintainer, btw). Perhaps this would be obvious to someone with more experience with state machines.
As a final example, I was attempting to show a UI element when certain child states were active. Naturally, I used "matches":
The state.matches(stateValue) method determines whether the current state.value matches the given stateValue.
If I am searching for "active", in the child parallel states "connected.LoggedIn.active" and "connected.LoggedOut.active", how do you match this?
state.matches("active"); // nope
state.matches("*active"); // nope, though wildcard syntax is supported elsewhere
// nope, it uses a separate object syntax
state.matches("connected.LoggedIn.active") &&
state.matches("connected.LoggedOut.active");
Instead, you use the "hasTag" method. I had not seen tags in any example, which made this solution unintuitive.
The state.hasTag(tag) method determines whether any state nodes in the current state value have the given tag.
How about this (seemingly) simply requirement: how do you update context based on the result of an asynchronous function? Where is that information found?
actions: [
({ event, context }) => {
context.value = event.output;
},
];
I only found this result by inspecting the state machine in a debugger at runtime. Again, this is probably not the proper way to do this, but I could not get it to work any other way.
Typescript
XState is written in typescript. However, my code is littered with:
// @ts-expect-error I don't know how to properly type events
input: ({ event: { paramName } }: { paramName: ParamType } => ({ paramName }))
As soon as my events include optional parameters, typescript breaks for events. In the docs there is a note about "asserting events" but the solution is "strongly recommend" against. The suggested solution of using "dynamic parameters" works only on events and guards, and not events.
In my Vue template, typescript does not recognize the function signatures of the state machine:
<!-- @vue-expect-error xstate typescript problem -->
<div v-if="stateMachine?.matches('someState')"></div>
Not a big deal, but since converting to Typescript I like to understand why errors are happening before I "expect-error" them.
Possible improvements
I wish xState had more error messages. When I send an event to a stopped machine, xState gives me a great console message that tells me why there is no state transition. If I give it an action that is invalid for some reason (like "assign" or "raise"), it fails silently. It was very difficult to debug why these were failing, and I still do not know what I was doing wrong in several cases.
I think the docs could use some extra links to related topics, like mentioning the "raise" event in the "state" or "events" docs.
Finally, some of the docs explanations are so dense with information that they are hard to grok. For instance:
useSelector(actor, selector, compare?, getSnapshot?)
A Vue composition function that returns the selected value from the snapshot of an actorRef, such as an actor. This hook will only cause a rerender if the selected value changes, as determined by the optional compare function.
I had read this several times and study the example to understand this is very similar to watch in Vue. Perhaps it might be worth running some of the docs through an LLM to get a little bit more verbose, easier to understand language.
Options going forward
While the state machine is an improvement over my previous code, I'm weighing its complexity and bundle size against the potential benefits. My choices come down to:
- Refactor further: Could I achieve similar clarity with smaller changes to the existing code?
- Lean on Pinia: Would my Vue store provide enough structure without XState's overhead?
- Status quo: Is the current solution maintainable enough for now, prioritizing other development?
- Hybrid approach: Isolate parts of the logic with @xstate/store to gain benefits incrementally?
Conclusion
Overall, my experience with XState highlights a trade-off common in software development. It offers powerful state management and enforces clear logic, but at the cost of a potentially frustrating learning curve. If you're considering XState, be prepared to invest significant time in mastering its concepts.