From Zustand to Xstate: the Zen of using the right tool

Featured on Hashnode

I've known and seen demos of Xstate for a while but never felt the need to use it in a project. My understanding was and still is that Xstate shines in complex flow. Recently I worked on a small personal project that I built with Zustand I thought this a good project to dive into. This article is going to be about my experience of switching.

TLDR;

The biggest advantage of Xstate is it comes to modeling complex business logic. Instead of juggling various flags or values to get things to work, as you often need to do in Zustand, Xstate allows you to align your coding approach with the actual mental model of your business logic. For example, you have a flow that is the most linear and sequential you can visualize is it in your stat chart as such. See the machine at Stately here.

About the project and the plan for migration

So the App we are working with allow users to create formatted tables from csv fiels . It works by using a dataModel to extract data and format csv files. The app is powered by a single Zustand store but broken down into two different slices. One slice is responsible for the creation of the formatted tables ("AutoTables"). The other slice is responsible for the creation and updating of user-defined DataModels. Another layer of complexity is that the store is persisted to localStorage.

When planning for migration to Xstate, I wanted as much as possible to do incremental migration to the Xstate. First I decided that I will start with the CreateAutoTableSlice. The thinking was that I can start using Xstate and not worry about persistence for now. However, I still needed somehow to be synced with any DataModels that are defined on the user device. Luckily the solution in Xstate was simple. In the first step of the machine, I added an invocation that fetches the data Models from local storage.

Rabid fire mental model of Xstate

Before delving deeper into the details, let's quickly establish a fundamental mental model of Xstate. This section aims to give you an essential grounding to better appreciate the rest of the article.

  • Everything is defined inside a machine*: Your business logic, side effects, context, state values—everything is defined and lives inside a single machine.

    • * There are patterns such as withConfig and the options parameter of the useMachine function, but these mostly augment the state machine rather than altering this fundamental characteristic.
  • Logic is modeled as several states connected by transitions: you define explicit state charts, which not only manage state but also elegantly map out the entire flow of our application logic.

  • Machin "Context": it's where you store data that can change over time and across states. It acts as a memory of sorts for your state machine, persisting data between state transitions.

  • No Built-in State Sharing: Xstate doesn't have inbuilt primitives for sharing states across different applications, unlike Zustand. But fear not, as there are well-established patterns for state sharing in Xstate, which we'll be exploring later.

  • Using Machines by "Interpreting" it: Finally, in a React application, you bring your machine to life by using useMachine or useInterpret. These hooks start a machine that runs for the lifetime of the component.

Lessons Learned

The code matches my mental model of the business

Often time when you start coding you have a fuzzy idea of how the business logic should work. This is because the natural language can't perfectly encapsulate the complexities of business operations.
Consequently, you end up in a cycle of coding, encountering unforeseen scenarios, refactoring, rewriting, and repeating until you achieve a working product that meets the business needs.

Having your code Matches the mental model of your business makes things much faster to develop. And even when you hit cases where you have to update the code to match your new understanding of the business logic is a lot easier. on top of all the the Xstate visualizer/editor, make things even easier.

There is a learning curve

No Surprise there is a learning curve and a bunch of new concepts and terminologies to learn for me. Also I never really got to a point where I felt I have a solid understanding of what exactly is an actor, I know that a service is an actor in Xstate and I know that an actor is something that "sends messages, receives messages and do some computation" but still for my brain that wasn't enough.

Luckily despite this disadvantage, I managed to get my way around Xstate and manage to migrate the AutoTablesCreationSlice to Xstate. It did however take a couple of days for me to feel comfortable and stop being frustrated when typescript screams at me. As usual with foreign new complex concepts, I need to sleep on it and that for some weird reason does help.

Dealing with Computed Context

💡
In the Xstate, what's known as "computed state" in Redux or Zustand becomes "computed context". This is because in Xstae a "state" represents a specific phase in the state machine, while "context" stores data that changes over time and across states.

Computed context is a common need in our application and often time they do include business logic. Unfortunately Xstate doesn't have the concept of computed/derived context. However, the xstate/react package does come with a useSelector. Initially, I tried to build a custom selector hook that will encapsulate all the available computed context. However, I apondened that approach after I learned that you lose the isolation of re-renders based on what changes by using useSelector. Instead, I create a DerivedContextSelectors to encapsulate all the business logic in one place.

export const autoTablesCreationMachineDerivedContextSelectors = {
  selectAvailableDataModelsNames: selectAvailableDataModelsNames,
  // more selectors here
} as const;

Hidden Bugs

I got stuck for 15m during the migration on an event that I can see from the dev-tool that is firing but the transition does not complete. I eventually found out that event firing was referencing the target state that was renamed. Though typescript did show an error in that file, the app didn't show anyting in the logs and didn't break. I wished that cases like this would break more loudly.

Actions can be fired from anywhere

This point was not immediately obvious to me and I was having a difficult time figuring out how to fire an error toast if the processing of uploaded files failed but still who the upload screen. I ended up doing what is called a self-transition and firing the error toast from the event.

Using the state Machine across components

As discussed earlier, we bring our state machine to life in a component by 'interpreting' it using either useMachine or useInterpret. This initiation creates distinct, disconnected flows for each component on each call, which may not always what you want. If we want different components to share the same flow, we can achieve this by using ReactContext.

export const AutoTablesCreationServiceContext = React.createContext<InterpreterFrom<typeof autoTablesCreationMachine> | null>(null);

// inside your component 
const service = useInterpret(autoTablesCreationMachine, { devTools: true });
// insde the return
 <AutoTablesCreationServiceContext.Provider value={service}>
    {children}
 </AutoTablesCreationServiceContext.Provider>

Then in any component, you can do this

const service = useContext(autoTablesCreationMachine);

I wanted to use the useActor hook to perform state.match (which is how you verify the state of your machine). But since the context is sometimes null this was a problem so I ended up creating a custom hook that asset strong types the context and throws if it's missing.

import { useContext, type Context } from 'react';

export function useRequiredContext<T>(context: Context<T | null>): T {
  const value = useContext(context);
  if (value === null) {
    throw new Error('Context value is missing');
  }
  return value;
}

Final Score

Using Xstate did require a different way of thinking about my application state. But it did ended-up for me to be a much more natural way of thinking about code and business logic. Despite the learning curve, I'm very pleased with the migration to Xstate. Going forward I'm going to explore migrating the next slice to Xstate and figure out the best approach strategy for persisting the machine's context. Stay tuned for a potential Part 2 of this article.