Ever had one of those game-changing discoveries, the kind that makes you reevaluate everything you thought you knew? For me, stumbling upon XState was akin to that. I often joke that my web dev journey has two phases: Before XState (BX) and After XState (AX). The difference? Clarity, efficiency, and a lot less hair-pulling when looking back at a codebase after months away from it.
With the newfound XState clarity and the ease of development that Next.js offers, I started to find front-end development a lot more enjoyable. Using XState with the Pages directory in Next.js was straightforward because all the pages were essentially client components, but with the new App Directory in Next.js, this becomes a bit complicated because of React Server Components (RSC). Having recently switched several personal projects over to the App Router because I’ve come to enjoy the new paradigm, here's a glimpse into my approach when mixing these two.
The draw of Xstate is its promise of simplicity through structured thinking. Instead of treating UI logic as an afterthought, you envision a distinct state machine for components with multiple states, making it easy to understand how your components work at a glance. It's a strategic and systematic way to handle varying UI states, and over time it becomes a natural way of creating components.
However, I’d be lying if I said learning how to use XState was a straightforward process. It wasn’t until I saw Matt Pocock’s Just Use Props article about integrating XState into React that things started to click. The article showed me two things:
With that out of the way, here’s what I’ve been doing:
This doesn’t mean you need to make your entire page a client component, rather it means you need to start thinking in terms of Component-Level State Machines. There’s a common misconception that Client Components are bad and you should avoid them. But this tends to discard the fact that before the App Router and RSCs, every page in the Pages directory was a client component. It’s why I like Theo’s framing of client components as ‘interactive components’, or windows of interactivity. And if your component has multiple states, these states likely change as a result of user interaction.
Let’s say we have a UserOnboarding page and it’s a server component. I would use it purely for data fetching, along with any other non-interactive parts of the page e.g. the layout or header (though you could also move those to the client as well if you wanted). I would then pass that data to the top-level interactive component.
Rather than creating an overarching state machine for an entire page, consider each component as its entity. However, not every component will need a dedicated state machine. This modular approach ensures clarity and reduces complexity.
Although it’s popular to have subcomponents also hold functionality, e.g having a reusable button that could be placed anywhere and when clicked would make a request to the server to do something, with XState, I prefer to use dumb components, or purely visual components with no innate functionality other than triggering events with props. Aside from making it easier to write tests for each component, it also means I can keep the general logic and functionality of the component within the state machine or hooks.
Here’s a SaaS Onboarding state machine example:
This state machine starts the user off at the point where they need to enter their name. Once that’s provided, they move to the state where they enter a name for their workspace or organization. After that, they’re shown an Onboarding Complete page.
With this logic, we can create a standalone component named OnboardingWizard:
With our state machine and the component set up, let’s take a second to understand how side effects play a role. XState allows for the execution of side effects via “actions” and "services". A service in XState is essentially an invoked action that happens over some duration – whether it be a promise, callback, or even another machine. Actions, on the other hand, are usually fire-and-forget. I like to use those for things like alerts or notifications.
In the OnboardingWizard component, the storeProfileData and createWorkspace services act as bridges between our UI logic and the business logic of the application. Instead of embedding fetch requests or other similar operations directly in our state machines, these are abstracted into services that are provided when interpreting the machine in a component. The state machine determines what to do when the promise resolves, or if an error occurs. This allows me to work with libraries like React Query and TRPC in React land, instead of trying to manually recreate some of the functionality of these libraries within the state machine. Plus, it keeps the state machine smaller and more readable.
Advantages of this approach:
Now I can pass in whatever async functions I want to OnboardingWizard component using props, and other components making use of OnboardingWizard don’t need to know anything about the state machine. Although native inter-machine communication is a big part of XState, I deliberately avoid using that feature mostly because it feels less React-like to me and also makes components overly coupled to each other in my experience.
You can’t pass functions, event handlers, or callbacks between Server components and Client components, so if you wanted to pass in handlers for the onStoreProfileData and onCreateWorkspace props, you would have to define the functions directly in the OnboardingWizard component or wrap it in a separate client component to provide the required API call functions as props.
One thing we can pass along directly, however, is data. For example, if we wanted to pre-fill the user’s name during onboarding (maybe because they signed up with social login, which provides that info), we could fetch that info about the user on the server, and then pass it to the OnboardingWizard as props.
When the page is loaded, that information will be available to the client in the state machine context.
While this is my preferred approach, it's essential to remember there's no one-size-fits-all. A lot of companies and devs prefer to structure their entire applications around state machines, including their backends, which is equally valid. Likewise, you might also decide that you want to use state machines in your server components as well, in which case, you could trigger transitions between states using parameters in the URL.
What resonates with me with this approach is the balance between structured logic and flexibility. It also makes it easy to reuse components across projects, because the core logic isn’t necessarily tied to the component but rather the state machine, which isn’t React-specific. Dive in, experiment, and maybe you'll find your distinct rhythm.