Why XState is Worth It

Ryan Padilla
Vincit
Published in
9 min readOct 3, 2023
Image of many orange interconnected pipes

If you’ve ever used React’s useState for managing state, you've likely experienced the shift from “oh, I'll just add another useState and move on” to “…another one?” So, you might have switched to useReducer or even ventured into Redux or similar libraries. These choices offer relief, but they often bring a new set of complexities, perhaps wrangling with multiple useEffect hooks. As a person who understands things visually, the mental overhead of lots of switch cases or if statements doesn’t make it easy.

However, there’s another option — a more straightforward path to handle state in your components. Enter XState.

In this blog post, I won’t attempt to sell you on XState. Instead, I’ll introduce it as an alternative, offering my perspective on state management in React components. State machines can simplify your code and bring clarity to your state transitions and allow you freedom to integrate with others. Let’s explore this option and see if it aligns with your needs.

First, Let’s Set the Stage

Like I mentioned, if you’re using a React project, more often than not you’ll be managing some state within useState hooks or similar, especially at the early stages of building a component. Let's say, for example, it's a modal. You'll probably need something to control whether the modal is open or not. Easy! You might even do it in one line, like

const [isFullMode, toggleFullMode] = useReducer((s) => !s, false);

However, most modals or components are not simply alerts that show you one thing; there are often different steps to them or a form to show. Now you’re adding state to handle those flows, and of course, there could be errors, so how do you account for those? Since you’re concerned about maintainability, you switch to useReducer and start grouping state and defining different actions that can take place.

But even with this approach, there are some drawbacks. The code can become verbose and complex, especially as your component evolves to handle more scenarios and transitions between different states. You might find yourself juggling multiple useEffect hooks to update the UI in response to state changes or handling errors. This approach can quickly become unwieldy and hard to maintain. Of course you can (and should!) separate concerns as best you can into multiple files, but doing that in pieces can be hard to determine when you should branch off. If you’re moving fast, refactoring is often put on the back burner.

Code Example

Here’s a relatively simple example: You’re building a widget that fetches a specific user’s information and displays it. This happens on page load, and you can also configure the widget to show a different user’s information. It sounds straightforward at first, but as your requirements evolve, managing state in React can quickly become a labyrinth of hooks, handlers, and conditions. Here’s one example of how it might look:

import React, { useState, useEffect } from 'react';

type User = {
name: string;
email: string;
website: string;
};

type ExampleContext = {
user: User;
error?: string;
};

const userLoader = (id: string): Promise<User> =>
fetch(`https://jsonplaceholder.typicode.com/users/${id}`).then(res =>
res.json()
);

const ModalComponent = () => {
const [context, setContext] = useState<ExampleContext>({
user: { name: '', email: '', website: '' },
});
const [currentState, setCurrentState] = useState<string>('loading');
const [formData, setFormData] = useState<{ id: string }>({ id: '1' });

useEffect(() => {
const fetchUserData = async (id: string) => {
try {
const user = await userLoader(id);
if (!user.email && !user.name && !user.website) {
throw Error('Not found');
}
setContext({ ...context, user, error: undefined });
setCurrentState('base');
} catch (error) {
setContext({
...context,
error: (error as { message: string }).message,
});
setCurrentState('error');
}
};

if (currentState === 'loading' || currentState === 'config-selectUser') {
fetchUserData(formData.id);
}
}, [currentState, formData.id, context]);

const toggleConfig = () => {
if (currentState.includes('config') && context.error) {
setCurrentState('error');
} else if (currentState.includes('config') && !context.error) {
setCurrentState('base');
} else {
setCurrentState(
currentState === 'base' || currentState == 'error'
? 'config-base'
: 'base'
);
}
};

const submitForm = () => {
setCurrentState('config-selectUser');
};

const handleRetry = () => {
setCurrentState('loading');
};

const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const { name, value } = e.target;
setFormData({ ...formData, [name]: value });
};

return (
<div>
{currentState === 'loading' && <p>Loading...</p>}
{currentState === 'error' && (
<div>
<p>Error: {context.error}</p>
<button onClick={handleRetry}>Retry</button>
<button onClick={toggleConfig}>Toggle Configuration</button>
</div>
)}
{currentState === 'base' && (
<div>
<p>User Name: {context.user.name}</p>
<p>User Email: {context.user.email}</p>
<p>User Website: {context.user.website}</p>
<button onClick={toggleConfig}>Toggle Configuration</button>
</div>
)}
{currentState === 'config-base' && (
<div>
<form>
<label>
Id:
<input
type='text'
name='id'
value={formData.id}
onChange={handleChange}
/>
</label>
<button type='button' onClick={submitForm}>
Submit
</button>
<button type='button' onClick={toggleConfig}>
Cancel
</button>
</form>
</div>
)}
{currentState === 'config-selectUser' && (
<div>
<p>Submitting user data...</p>
</div>
)}
</div>
);
};

export function App() {
return <ModalComponent />;
}

Let's break down some of the key challenges and complexities that start to emerge:

  1. Multiple States and Transitions: Initially, you have a simple “loading” state to fetch user data. But as your widget evolves, you introduce additional states like “base,” “config-base,” “config-selectUser,” and “error.” Keeping track of these states and their transitions becomes increasingly challenging.
  2. Conditionals and State Checks: Notice how you’re using conditionals like if (currentState.includes('config') && context.error) to determine which state to transition to. These conditionals can quickly become convoluted and error-prone as your component grows.
  3. Data Handling and Validation: You’re managing form data and handling user input with formData and the handleChange function. As you introduce more form fields and validation logic, the component's complexity further increases.
  4. UI Updates: Handling UI updates based on different states can lead to a proliferation of conditional rendering logic, making your component harder to read and maintain.
  5. Fetch Logic: Fetching user data involves asynchronous operations and error handling. Managing these operations within the useEffect block can result in complex control flow.
  6. User Interaction: As you introduce more user interactions like toggling configuration and retrying operations, you must carefully manage the state transitions and side effects associated with each interaction.

While the example is relatively simple, it demonstrates how the complexity of managing state in React components can grow rapidly as you add features and handle various scenarios. This complexity can lead to code that is difficult to understand, maintain, and extend.

Where XState Comes In

Now managing how closed systems should function is not a new problem, and state machines have been around for quite some time. However, as a javascript paradigm I feel they are underrated. I remember learning about state machines myself in college, in an electrical engineering adjacent course. They made an impression on me, however I think I wrote them off as “This is just if statements but fancier,” and I didn’t see them pop up in my realm of software engineering for quite some time. In my latest project, 4 years later, a colleague put me onto this library called XState, which brought state machines back into the forefront for me.

I grew to love the concise machine definition with named states and transitions. Having named effects that run in easy to find transitions makes debugging so much easier. While I don’t use the VSCode plugin visual editor to edit anything, I find it’s invaluable in creating a mental model and following the flow of your code. In the end, who doesn’t appreciate a good drawing? Testing is also made easier as you can design around testing certain flows and it is simpler to assert on states you think should happen based on that.

With that in mind, let me show you a version of the above example in two parts, the machine definition and then the UI implementation.

import { assign, createMachine } from 'xstate';

type User = {
name: string;
email: string;
website: string;
};

type ExampleContext = {
user: User;
id?: string;
error?: string;
};

const userLoader = (id: string): Promise<User> =>
fetch(`https://jsonplaceholder.typicode.com/users/${id}`).then((res) => res.json());

export const exampleMachine = createMachine(
{
/** @xstate-layout N4IgpgJg5mDOIC5RgB4EMC2AHANmAsmgMYAWAlgHZgB0ARmrGAMQAqA8gOIcAyAogPoBhNgDkAYgEkOAbQAMAXUSgsAe1hkALmRUUlIFIgDsANgCM1AByHTATkOyATABYAzCdk2ANCACeiAKxOxtROpv6GLrIWzoHGNgC+8d6omLgExORU1DgqaBCUUEwAygCqgoK8RUVyikggqupaOnoGCIYWFtR2LoGyEa6msqbefggOZtSmAw42Dg4uNjb+FonJ6Nh4hKSUNDl5BUy8AEpHbEc1eg2a2rp1rS4L1NYdQw6DpsbOTiOID11O-mWcxsFmMhmWplW4HWaS2mRoYAAToiVIimEdeCwjgBNC51K5NW6gVqDFxdGbGfzzYyyGmGZw-BCmByGajLIYRQbtKlxKEpDbpbZZJEotHsLh8ISiSQyBSXNTXZp3RDMhxPKztWzvYxgxla6iUuJuaxxfyfJx8mGbDI7ahEHQAMzIhQgOhosA0aA0CKtgvhdsdzrxygVhJaRlMZMMdje-jsFicDlk318iCcFn8BsM7RcVh1ziWltS1qFNHtFCdhXFPAEwnEUmD9VDN3DCBpar60dCxiCAPpersk3pPbBuZmpmZRYFcNt5crrE4Nal9ZkplqIcaLeVbds6sCCb6tnzepZbIsHJcXIzLIcU9hNqyc+ddAYzFKACF8BIWI2CVviSqoLUEml7zH0Ng0jYQyMgsapgjGyx5v4LjGHeJb+k+UDUIweBEBoJSMGirpZJQABuKgANY+sWfqzoGWE4WAeEEUiCBkSoRBejcNS-s2SoAQgTg2E4kxmiyPa9j0wypggPTBEsl7GBYdgTqqLhobRj70dhYC4fhhFMCKqLULgXoOqiGDUPy96lgGFbPoxzGEWxFDkZxhI8XK+J8US+i-Ga1CyLI-i0hYKGBOEJh6vYkznm4sg9MhjhWBpM5afZDEAK5EEQcCwA6mU4EwKAel6NBoA63qIgAFIMQUAJSGb6aVltpsDZblsD5YVvGbvxfljJSBqDOEEEsrSZjGIyyGdKYFgTg8OpCaYESJEkIAUCoEBwHo1noTs8p9b5rQALTMiJNhzfMUGBJG4yGIyJ3IWygShGFQzIRSqUPjQ9CMIdirHYg542JMEFzedthmjBCZPJdHhBNE7TJt9tl7PkFBQADYbbidJhdFdCzBaELj3YyrjUKBQUJShQU5qj-pGYi2P-gNVJkuECU0v4K1xNJoyRsEYWLNSsw9tmDN0RlLP9a0AJdDSHQzIYiZhTqjLjKDAKXirM1Cf4kvpZWL7-d5R2tnYME2GSK0IR0JjIah617ZprUZTpeksczZuA62kSsomc3WOBix9P4MFxFmdjppE1tBC4FrO81P12cb7U5XlBU4DLQOCbu8ygh0swTqFD0yY7BrzWE8yax0kJJzRLWp8+xFgDnFtkhBUTRNHDhq1N5c9CEl5TDNfe22t8RAA */
id: 'exampleMachine',
schema: {
context: {} as ExampleContext,
events: {} as
| { type: 'TOGGLE_CONFIG' }
| { type: 'SUBMIT'; id: string }
| { type: 'ERROR'; error: string }
| { type: 'SUCCESS'; user: User }
| { type: 'RETRY' },
services: {} as {
loadUser: {
data: User;
};
},
},
initial: 'loading',
states: {
base: {
on: {
TOGGLE_CONFIG: 'config',
},
},
loading: {
invoke: {
src: 'loadUser',
onDone: { target: 'base', actions: ['setUser'] },
onError: { target: 'error', actions: ['setError'] },
},
},
error: {
id: 'error',
on: {
RETRY: 'loading',
TOGGLE_CONFIG: 'config',
},
},
config: {
initial: 'base',
states: {
base: {
on: {
SUBMIT: { target: 'selectUser', actions: ['setId'] },
},
},
selectUser: {
invoke: {
src: 'loadUser',
onDone: { target: 'successful', actions: ['setUser'] },
onError: { target: '#error', actions: ['setError'] },
},
},
successful: {
after: {
1000: 'done',
},
},
done: {
type: 'final',
},
},
on: {
TOGGLE_CONFIG: [{ target: 'base', cond: 'hasNoErrors' }, { target: 'error' }],
},
onDone: 'base',
},
},
tsTypes: {} as import('./exampleMachine.typegen').Typegen0,
},
{
services: {
loadUser: (context, event) => {
const id = event.type === 'SUBMIT' ? event.id : context.id;
return userLoader(id!).then((data) => {
if (!data.name || !data.email || !data.website) {
throw new Error('User not found');
}
return data;
});
},
},
actions: {
setUser: assign((context, event) => {
return { ...context, user: event.data, error: undefined };
}),
setId: assign((context, event) => {
return { ...context, id: event.id };
}),
setError: assign((context, event) => {
return { ...context, error: (event.data as { message: string }).message };
}),
},
guards: {
hasNoErrors: (context) => {
return !context.error;
},
},
}
);

And here is the App.tsx file that renders the UI:

import { useMachine } from '@xstate/react';
import { useForm } from 'react-hook-form';
import { exampleMachine } from './exampleMachine';

const ModalComponent = () => {
const [{ context, matches }, dispatch] = useMachine(exampleMachine, {
context: { id: '1' },
});
const inputForm = useForm({ defaultValues: { id: '1' } });

const toggleConfig = () => dispatch('TOGGLE_CONFIG');

const submitForm = ({ id }: { id: string }) => {
dispatch({ type: 'SUBMIT', id });
};

const handleRetry = () => {
dispatch({ type: 'RETRY' });
};

return (
<div>
{matches('loading') && <p>Loading...</p>}
{matches('error') && (
<div>
<p>Error: {context.error}</p>
<button onClick={handleRetry}>Retry</button>
<button onClick={toggleConfig}>Toggle Configuration</button>
</div>
)}
{matches('base') && (
<div>
<p>User Name: {context.user.name}</p>
<p>User Email: {context.user.email}</p>
<p>User Website: {context.user.website}</p>
<button onClick={toggleConfig}>Toggle Configuration</button>
</div>
)}
{matches('config.base') && (
<div>
<form onSubmit={inputForm.handleSubmit(submitForm)}>
<label>
Id:
<input type='text' {...inputForm.register('id')} />
</label>
<button>Submit</button>
<button type='button' onClick={toggleConfig}>
Cancel
</button>
</form>
</div>
)}
{matches('config.selectUser') && (
<div>
<p>Submitting user data...</p>
</div>
)}
{matches('config.successful') && (
<div>
<p>Welcome {context.user.name}!</p>
</div>
)}
</div>
);
};

export function App() {
return <ModalComponent />;
}

Here’s a working version that you can check out as well. There’s a lot to unpack here, so lets take a look at some of the advantages:

  1. Declarative State Machine: With XState, you define a state machine that explicitly states all the possible states your component can be in. This declarative approach provides a clear, visual representation of your component’s behavior.
  2. Explicit State Transitions: Each state in the machine defines its own transitions, making it easy to understand how your component can move between states. Additionally, you can include delays and conditionals for more fine grained control.
  3. Asynchronous Tasks: XState offers built-in support for handling asynchronous operations, such as fetching data. There are two cases where I “invoke” a service, which in XState can be a Promise, a callback, an observable, or another machine. This is one of my favorite features of XState, I learned about it after I had already done some workarounds to accomplish the same thing. Once you grasp this feature, you can really begin to move further away from useEffects and async handling outside of the machine.
  4. Error Handling: Error handling is integrated seamlessly into the state machine. You can specify how to handle errors within specific states, making it easier to manage error-related UI and logic.
  5. Code Organization: Your code is organized into clearly defined states and transitions, making it easier to read and maintain. You no longer need to manage a complex web of conditional statements and handlers.
  6. Improved Testing: Testing becomes more straightforward as you can test each state and transition independently, ensuring that your component behaves as expected in various scenarios.
  7. Separation of Concerns: XState integrates well with libraries like react-hook-form for handling forms. In this example, the form handling code is concise and easy to understand, promoting a clean separation of concerns.

Words of Advice

Exciting stuff right? Going to do everything with state machines now? Well hold onto your horses a bit, because like all things software dev, this is no silver bullet. I’ve found that, while an XState machine can hold items within its context very similarly to what Redux can do, it feels awkard if you use it as a global store. It can be done, but there were times where I wished I had simply used Redux or React context. State machines excel at states with known transitions, but if you don’t need to transition or are unsure of where and when transitions will be happening, you probably don’t need the overhead. Additionally, it may be tempting to use for routing, but only if you don’t need to worry about browser related navigation or history.

In Conclusion

After using XState in a recent project, I can confidently state (ha) that it’s improved the way I write my components and saved me countless hours in debugging. It’s also a lot easier for other developers to consume, as there’s a more rigid structure and the visual editor / visualizer is a big help. I’ve enjoyed the learning process and feel I am better prepared to tackle component development in the future!

--

--