tech

How I use statecharts in production

I have been using statecharts for over 1.5 years in production. And it's working great, development speed and no bugs. These are some common patterns I often use with statecharts in XState.


Sandro Maglione

Sandro Maglione

Software

Statecharts are the ideal model for state management on the frontend 💯

I have been using statecharts (xstate) for a while now. These are some of the most common patterns I use, in production 👇


Disclaimer: State gets complex

"xstate is overenginerring". "Statecharts are too complex". "useState is fine".

The most common critiques of xstate don't take in consideration the nature of state:

State has a tendency to get exponentially more and more complex with each feature 🤯

State that may seem "fine" using useState will eventually force one of two choices:

  1. Keep using useState and manually manage more and more complexity
  2. Refactoring state to use more advanced techniques/libraries

Option 1 leads to more and more tech debt, errors, and slowness (both performance and development time).

Option 2 is expensive for the business, since refactoring means stop shipping features.

Therefore, I lean for a third option:

Always implement state using statecharts from the start (xstate) 🙌

No matter how simple the state, I use xstate. It's always the case that the state gets more complex, and I am already covered by the benefits of statecharts.

Note: It also depends on the kind of state.

Read here for more details: All kinds of state management in React

Single actor/machine

For simple flows, I implement a single machine directly on top of the component, in the same file:

import { useMachine } from "@xstate/react";
import { setup } from "xstate";

const machine = setup({
  types: {},
  actors: {},
}).createMachine({
  // ...
});

export default function LoginEmailForm() {
  const [snapshot, send] = useMachine(machine);
  return (
    // ...
  )
}

I don't need to jump between multiple files. machine is not exported, but specific for that single component.

If the component is not used anymore, you can confidently remove the machine as well 💁🏼‍♂️

Reuse logic

Some machines are more generic. An example is this copy-text machine:

import { setup } from "xstate";

export const machine = setup({
  types: {
    events: {} as { type: "copy"; text: string },
  },
}).createMachine({
  initial: "Idle",
  states: {
    Idle: {
      on: {
        copy: {
          target: "Copied",
          actions: ({ event }) => {
            window.navigator.clipboard.writeText(event.text);
          },
        },
      },
    },
    Copied: {
      after: {
        2000: "Idle",
      },
    },
  },
});

Since copying text is common in multiple pages, I implement this machine in isolation. I then export and share it (in a monorepo for example):

import * as CopyTextMachine from "./copy-text";
import * as UploadFileMachine from "./upload-file";

export { CopyTextMachine, UploadFileMachine };

With xstate you can compose logic using actors. There are multiple way of doing this (check out Patterns for state management with actors in React with XState for more details):

const hubMachine = setup().createMachine({ /* ... */ });
const blogMachine = setup().createMachine({ /* ... */ });

export const machine = setup({
  types: {
    children: {} as {
      hub: "hubMachine";
      blog: "blogMachine";
    },
  },
  actors: { hubMachine, blogMachine },
}).createMachine({
  // ...
});

Complex flow

When the user flow becomes even more complex (think multiple steps, each with validation and context), then I split the logic in smaller machines:

  • "Main" machine manages flow steps
  • Each step of the flow is isolated in a different machine (actor)
const step1Machine = setup({}).createMachine({ /* ... */ });
const step2Machine = setup({}).createMachine({ /* ... */ });
const step3Machine = setup({}).createMachine({ /* ... */ });

export const machine = setup({
  types: {
    events: {} as { type: "next" },
    children: {} as {
      step1: "step1Machine";
      step2: "step2Machine";
      step3: "step3Machine";
    },
  },
  actors: {
    step1Machine,
    step2Machine,
    step3Machine
  },
}).createMachine({
  initial: "Step1",
  states: {
    Step1: {
      on: { "next": "Step2" },
    },
    Step2: {
      on: { "next": "Step3" },
    },
    Step3: {},
  },
});

Each sub-machine can be implemented in isolation. Only the final "main" machine is exported.

This strategy reduces complexity for each single machine. The "main" machine is then responsible to coordinate the flow ✋

Bonus: Validation with guards

Validation is often required for state management. I use guards + effect (Schema):

export const machine = setup({
  guards: {
    isValid: ({ context }) =>
      Either.isRight(
        Schema.decodeEither(
          Schema.Struct({
            displayName: Schema.NonEmptyString,
            bio: Schema.NonEmptyString.pipe(Schema.maxLength(MAX_BIO_LENGTH)),
          })
        )({
          displayName: context.displayName,
          bio: context.bio,
        })
      ),
  },
}).createMachine({
  on: {
    "continue": {
      guard: "isValid",
    },
  }
});

Schema gives you any sort of filter and refinement. You then use can inside the component:

export function CreateHubForm({
  actor,
}: {
  actor: ActorRefFrom<typeof machine>;
}) {
  const isValid = useSelector(
    actor,
    (snapshot) => snapshot.can({ type: "continue" })
  );

  return (
    <button disabled={!isValid}>Continue</button>
  );
}

effect keeps trending (it's not by chance, and it's not "just" a trend):

Once again, check out my Effect: Beginners Complete Getting Started if you are new to effect 🤝

See you next 👋

Start here.

Every week I dive headfirst into a topic, uncovering every hidden nook and shadow, to deliver you the most interesting insights

Not convinced? Well, let me tell you more about it