Rethinking State Machines in React

Ismayil Khayredinov
JavaScript in Plain English
5 min readFeb 13, 2021

--

Illustration Credit: Careerfoundry

I think state machines are a great fit for front-end development and they have been a part of my toolkit for over a year now. There are great FSM implementations out there, such as xstate and Robot, and they work well with React applications at scale, making complex user flows easier to implement. Lately however I have become more hesitant about bringing them into the workflow, without a strong enough justification, as they seem to bloat up the code with extra boilerplate and often interfere with the way I reason about interfaces and the role they play in user interaction with the application store/state.

1. An error is not always a global state

I find it increasingly problematic that a failed transition in above implementations forces you to alter the state or context of an entire machine. When we think about user interfaces and user interactions, a rectifiable error should not lead an entire machine to a failed state. A component invoking a transition from state A should be able to report that an error has occurred and enumerate the steps that can be taken to rectify the issue, without an entire application rerendering in a new failed state. Think of a server-side validation that prevents a certain update from taking place, but does not mean that the transition is not at all possible.

A transition is a series of actions that need to be taken to get a machine from state A to state B. If any of the actions fail than the machine should probably remain in the same state A, and not be forced to transition to state C.

A component invoking a transition knows best what to do with the error: it can choose to popup a message to the user, change it’s behavior or transition the machine to a different state. Handling errors inside the machine leads to a decontextualisation of failures — neither changing the machine state nor storing an error in a machine context helps the user deal with errors in the context of an action they were performing.

2. Pending transitions should not require an intermediary state

Ongoing transitions, similar to errors, should not require a state machine to change its state. A pending transition either succeeds leading to a new state and context changes, or it fails keeping the machine in the same state with the same context, to prevent unnecessary rerenders and lots of boilerplate to account for loading states in the UI.

An ongoing transition is a promise and it should work well with Suspense and other async strategies to provide feedback to the user. When a click on a button starts a transition, you want to simply show a spinner on the button, not rerender an entire application is some new loading state with a skeleton that matches neither previous nor next screen.

3. State management should not be a bottleneck

React hooks already provide everything you need for efficient state management and reactivity. Using pub/sub to communicate with machines created outside of React components creates a new storage layer that needs to be kept in sync. If you are already using a state management library a-la Recoil you are left with a bit of a headache, maintaining multiple incompatible storage mechanisms.

It is also increasingly difficult to synchronise your local state with the server, when every promise is treated like a submachine that can affect the entire machine state.

Tackling the problems with a home-grown solution

So, I have been playing around with these concerns and ideas and came up with a stripped-down implementation of a state machine done the React-way, without all the complexities of a mathematical state machine. Let me walk you through it.

Let’s presume we want to build an app that will allow us to send a message to the server, but will keep a draft in the local storage whenever we make changes.

You could read this as follows: When send transition is executed in writing state, call a chain of handlers, and if all are successful, propagate the target state and context.

As you can see, each handler will receive state machine context and payload as arguments.

Let’s create a context that will serve as a source of truth for our machine.

Now, let’s create a hook factory that will allow us to create a transition handler from it’s name. In this example I use react-async-hook, but you can swap it for whatever implementation you already use in your existing app.

So, we are creating an async handler that will chain all handlers we have defined for our transition. The state machine context will not be mutated until all handlers have successfully resolved. The result of useAsyncCallback is an object { execute, error, loading, result } . So we can call our transition handler with a specific payload and react to error/loading states within the component that calls it.

Now, let’s decorate our state a little for ease of access.

We can now, easily access the machine context and state, with some helper functions from any component in the tree.

Let’s also create some helper components. <Transition> can be used to transition machine through JSX (similar to react router’s <Redirect>). And <State> will allow us to check current state to decide if we should render the component. This will eliminate conditionals in our JSX.

Now, let’s build our app.

Our <ComposerView> will be a controlled component, with the machine state used as the source of truth.

And finally the <MessageView> that will display a sent message.

Conclusion

That’s it. We now have a barebones state machine without all the fuss that executes the transitions without requiring intermediary state changes or context mutations. You can expand on this with custom hooks, e.g. to push or replace browser state. You can also integrate this easily with any other React library and hooks system. You can also use hooks inside your handlers, e.g. to access current user’s JWT, to avoid having to sync the machine context endlessly with all the data you may need to perform a transition.

Looking forward to your comments.

--

--

Full-stack developer, passionate about front-end frameworks, design systems and UX.