Interactive
October 25, 2023

An Opinionated Approach to Using XState with Next.js App Router and RSCs

An Opinionated Approach to Using XState with Next.js App Router and RSCs

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.

XState's Appeal

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:

  • You don’t have to use every part of a library just because the features are there.
  • You don’t have to use XState in every component. In my case, I only really jump to xstate when I see that I’m using useState() more than a couple of times in a component.

With that out of the way, here’s what I’ve been doing:

1. Keep State Machines to Client Components

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.

2. Component-Level State Machines and Dumb Components:

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:


// onboarding-state-machine.ts
import { assign, createMachine } from "xstate";

export const userOnboardingStateMachine = createMachine(
  {
    id: "userOnboardingStateMachine",
    initial: "profileSetup",
    schema: {
      services: {} as {
        /**
         * Handler for storing profile data, called when we've received the profile data from the user.
         */
        storeProfileData: {
          data: void;
        };
        createWorkspace: {
          data: void;
        };
        onOnboardingCompleteHandler: {
          data: void;
        };
      },
      context: {} as {
        firstName: string;
        lastName: string;
        workspaceName: string;
      },
      events: {} as
        | {
            type: "STORE_PROFILE_DATA";
            value: {
              firstName: string;
              lastName: string;
            };
          }
        | {
            type: "CREATE_WORKSPACE";
            value: {
              workspaceName: string;
            };
          }
        | {
            type: "RETURN_TO_PROFILE_SETUP";
          },
    },
    context: {
      firstName: "",
      lastName: "",
      workspaceName: "",
    },
    states: {
      profileSetup: {
        initial: "waitingForInput",
        states: {
          waitingForInput: {
            on: {
              STORE_PROFILE_DATA: {
                target: "storingProfileData",
                actions: ["addProfileDataToContext"],
              },
            },
          },
          storingProfileData: {
            invoke: {
              id: "storeProfileData",
              src: "storeProfileData",
              onDone: {
                target: "#userOnboardingStateMachine.workspaceCreation",
              },
              onError: {
                target: "waitingForInput",
                actions: ["onErrorStoringProfileData"],
              },
            },
          },
        },
      },
      workspaceCreation: {
        initial: "waitingForInput",
        states: {
          waitingForInput: {
            on: {
              CREATE_WORKSPACE: {
                target: "creatingWorkspace",
                actions: ["addWorkspaceNameToContext"],
              },
              RETURN_TO_PROFILE_SETUP: {
                target: "#userOnboardingStateMachine.profileSetup",
              },
            },
          },
          creatingWorkspace: {
            invoke: {
              id: "createWorkspace",
              src: "createWorkspace",
              onDone: {
                target: "#userOnboardingStateMachine.onboardingComplete",
              },
              onError: {
                target: "waitingForInput",
                actions: ["onErrorCreatingWorkspace"],
              },
            },
          },
        },
      },
      onboardingComplete: {
        type: "final",
      },
    },
  },
  {
    actions: {
      addProfileDataToContext: assign({
        firstName: (ctx, evt) => evt.value.firstName,
        lastName: (ctx, evt) => evt.value.lastName,
      }),
      addWorkspaceNameToContext: assign({ workspaceName: (ctx, evt) => evt.value.workspaceName }),
    },
  }
);

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:


// OnboardingWizard.tsx
"use client";
import { useMachine } from "@xstate/react";
import { userOnboardingStateMachine } from "./onboarding-state-machine";

interface Props {
  initialContext?: {
    firstName: string;
    lastName: string;
    workspaceName: string;
  };
  onStoreProfileData?: (data: { firstName: string; lastName: string }) => Promise<void>;
  onCreateWorkspace?: (data: { workspaceName: string }) => Promise<void>;
}

const OnboardingWizard = (props: Props) => {
  const [state, send] = useMachine(userOnboardingStateMachine, {
    context: props.initialContext,
    services: {
      storeProfileData: async (ctx, evt) => {
        if (props.onStoreProfileData) {
          await props.onStoreProfileData(evt.value);
        }
        // Store the profile data in your database
      },
      createWorkspace: async (ctx, evt) => {
        if (props.onCreateWorkspace) {
          await props.onCreateWorkspace(evt.value);
        }
      },
    },
    actions: {
      onErrorStoringProfileData: (ctx, evt) => {
        // Show Error Toast
      },
      onErrorCreatingWorkspace: (ctx, evt) => {
        // Show Error Toast
      },
    },
  });

  return (
    <div>
      {/* Profile Setup State */}
      {state.matches("profileSetup.waitingForInput") && (
        <div>
          <h2>Profile Setup</h2>
          <form
            onSubmit={(e) => {
              e.preventDefault();
              const formData = new FormData(e.currentTarget);
              const firstName = formData.get("firstName") as string;
              const lastName = formData.get("lastName") as string;
              send({
                type: "STORE_PROFILE_DATA",
                value: {
                  firstName: firstName,
                  lastName: lastName,
                },
              });
            }}
          >
            <input type="text" name="firstName" placeholder="First Name" />
            <input type="text" name="lastName" placeholder="Last Name" />
            <button type="submit">Next</button>
          </form>
        </div>
      )}

      {/* Workspace Creation State */}
      {state.matches("workspaceCreation.waitingForInput") && (
        <div>
          <h2>Create a Workspace</h2>
          <form
            onSubmit={(e) => {
              e.preventDefault();
              const formData = new FormData(e.currentTarget);
              const workspaceName = formData.get("workspaceName") as string;
              send({
                type: "CREATE_WORKSPACE",
                value: {
                  workspaceName: workspaceName,
                },
              });
            }}
          >
            <input type="text" name="workspaceName" placeholder="Workspace Name" />
            <button type="submit">Create</button>
            <button type="button" onClick={() => send({ type: "RETURN_TO_PROFILE_SETUP" })}>
              Back
            </button>
          </form>
        </div>
      )}

      {/* Onboarding Complete State */}
      {state.matches("onboardingComplete") && (
        <div>
          <h2>Onboarding Complete</h2>
          <p>
            Welcome, {state.context.firstName} {state.context.lastName}!
          </p>
        </div>
      )}
    </div>
  );
};

export default 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:

  • Separation of concerns: UI logic remains in the machine, and business logic (like API requests) remains outside.
  • Easy testing: Since side effects are abstracted, we can mock them easily and test our machine in isolation using dependency injection.

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.

Integrating with Server Components

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.


// MyServerComponent.tsx
import OnboardingWizard from "./OnboardingWizard";

interface Props {}

const MyServerComponent = (
  props: Props,
) => {
  // Passing this function to OnboardingWizard wouldn't work because OnboardingWizard is a client component and MyServerComponent is a server component
  async function doSomething(data: { workspaceName: string }) {
    // Do something with the data
  }
  return (
    <OnboardingWizard onCreateWorkspace={doSomething}/>
  )
}

export default MyServerComponent

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.


// MyServerComponent.tsx
import OnboardingWizard from "./OnboardingWizard";

interface Props {}

const MyServerComponent = async (props: Props) => {
  // We could fetch the initial config related to the user here
  async function getInitialContext() {
    // Do something with the data
    return {
      firstName: "John",
      lastName: "Doe",
      workspaceName: "My Workspace",
    };
  }

  const initialContext = await getInitialContext();

  return <OnboardingWizard initialContext={initialContext} />;
};

export default MyServerComponent;

When the page is loaded, that information will be available to the client in the state machine context.

In Conclusion

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.