โ€ข

tech

XState v6 Alpha

The new major version of XState (v6) is now out in alpha and ready to be tested. I already migrated a full codebase and sent 7 PRs with improvements. Let's see what's new.


Sandro Maglione

Sandro Maglione

Software

XState v6 is out in alpha ๐Ÿš€

Published an alpha for next version of XState last week, with some updates since then Main themes: removed a *lot* of API surface area (no more awkward actions/guards/assign/etc), much more natural & type-safe Current migration docs if you're curious: github.com/statelyai/xstaโ€ฆ

David K ๐ŸŽน
David K ๐ŸŽน
@DavidKPiano

As promised, xstate@alpha published. Summary here; more info + blog post soon github.com/statelyai/xstaโ€ฆ

Reply

I already migrated one of my codebases to v6, and sent 7 PRs with various improvements โšก๏ธ

This is huge, let me show you why ๐Ÿ‘‡


Mostly the same, but not really

This is not a complete API change, machines on the surface looks mostly the same:

  • setup
  • createMachine
  • events, context, transitions etc.
const foodsHubMachine = setup({
  schemas: {
    context: types<{ readonly activeTab: 0 | 1 }>(),
    events: {
      selectTab: types<{ readonly index: 0 | 1 }>(),
    },
  },
}).createMachine({
  context: {
    activeTab: 0,
  },
  on: {
    selectTab: ({ event }) => ({
      context: {
        activeTab: event.index,
      },
    }),
  },
});

This is good: no complete overhaul of your code to migrate to v6 ๐Ÿ’๐Ÿผโ€โ™‚๏ธ

But there is more hiding behind the API, and it's huge ๐Ÿ‘‡

Schema-first

v5 has this weird-looking way of making things type safe with {} as { ... }:

const backupRouteMachine = setup({
  types: {
    context: {} as {
      readonly backupName: string;
      readonly errorMessage: string | null;
      readonly successMessage: string | null;
    },
  },

But since then schemas emerged, and now v6 is all based on Standard Schema:

import { Schema } from "effect"; // ๐Ÿ‘ˆ effect fully supported

const foodsHubMachine = setup({
  schemas: {
    // Instead of {} as { activeTab: 0 | 1 }
    context: Schema.toStandardSchemaV1(
      Schema.Struct({
        activeTab: Schema.Literals([0, 1]),
      })
    ),
    events: {
      // Instead of {} as { type: "selectTab", index: 0 | 1 }
      selectTab: Schema.toStandardSchemaV1(
        Schema.Struct({
          index: Schema.Literals([0, 1]),
        })
      ),
    },
  },
})

setup includes schema for everything, included states ๐Ÿ‘‡

State input aka state-specific context

In v5, context holds everything, for every state.

This caused issues with context that is state-specific ๐Ÿค”

A simple example: error/success messages.

In v5, you are required to carry a string | null in context, even if they are shown only when the Success or Error state are active:

const backupRouteMachine = setup({
  types: {
    context: {} as {
      readonly backupName: string;
      readonly errorMessage: string | null;
      readonly successMessage: string | null;
    },
  },

In v6, you can store message as a string directly inside Success/Error:

const exportBackupMachine = setup({
  states: {
    Idle: {},
    Exporting: {},
    Error: {
      // ๐Ÿ‘‡ Specific for `Error`
      schemas: {
        context: Schema.toStandardSchemaV1(
          Schema.Struct({
            message: Schema.NonEmptyString,
          })
        ),
      },
    },
    Success: {
      // ๐Ÿ‘‡ Specific for `Success`
      schemas: {
        context: Schema.toStandardSchemaV1(
          Schema.Struct({
            message: Schema.NonEmptyString,
          })
        ),
      },
    },
  },

Inside components, you can use matches("Success") to narrow down the context and get type-safe message from it:

function ExportBackupSection() {
  const [snapshot] = useMachine(exportBackupMachine);
  return (
    <View>
      {snapshot.matches("Success") && (
        <Notice message={snapshot.context.message} />
      )}

      {snapshot.matches("Error") && (
        <Notice message={snapshot.context.message} />
      )}
    </View>
  );
}

This fundamentally changes how you structure machines. Instead of a giant context, you will slim it down (close to nothing really), and spread all around states instead.

This new model prevents many issues related to forgetting to set context, forgetting to reset it, accessing a context value in an impossible branch etc. ๐Ÿช„

No more assign confusion

v6 removes all those function imports like assign, sendParent, spawnChild.

Instead, a single enq function gives you all that you need.

This is similar to v5's enqueueActions API ๐Ÿ‘€

Handling events becomes a single function. You return target, context (in full!), and call enq for actions, actors and more:

In most cases, you need to spread the full context yourself ...context ๐Ÿ‘€

const foodsHubMachine = setup({
  // ...
}).createMachine({
  context: {
    activeTab: 0,
  },
  on: {
    // Return full updated context, no need of `assign`
    selectTab: ({ event }) => ({
      context: {
        activeTab: event.index,
      },
    }),
  },
});

fromPromise becomes createAsyncLogic

The API for creating an actor also changed.

Instead of fromPromise with a function inside it, you get createAsyncLogic with schemas, run, timeout:

const addMealFoodRouteMachine = setup({
  actorSources: {
    addMealEntry: createAsyncLogic({
      schemas: {
        input: Schema.toStandardSchemaV1(AddMealEntryInput),
        output: Schema.toStandardSchemaV1(AddMealEntryResult),
      },
      run: ({ input }) =>
        RuntimeClient.runPromise(
          Effect.gen(function* () {
            // ...
          })
        ),
    }),
  },
}).createMachine({
  // ...
});

Inside createMachine you have the usual onDone/onError:

I am mapping this to my effect mental model: onError for defects, onDone for tagged success and failures. Then use Match with tagsExhaustive ๐Ÿ‘‡

const addMealFoodRouteMachine = setup({
  // ...
}).createMachine({
  states: {
    // ...
    Submitting: {
      invoke: {
        src: "addMealEntry",
        input: ({ context }) => {
          // ...
        },
        onDone: ({ actions, context, event }, enq) =>
          Match.value(event.output).pipe(
            Match.tagsExhaustive({
              FoodNotFound: () => ({
                target: "EnteringQuantity",
                context: {
                  notice:
                    "Could not find that food. Pick another food and try again.",
                },
              }),
              MealNotFound: () => ({
                target: "EnteringQuantity",
                context: {
                  notice:
                    "Could not find that meal. Pick another meal and try again.",
                },
              }),
              SchemaError: () => ({
                target: "EnteringQuantity",
                context: {
                  notice: "Enter a quantity greater than zero.",
                },
              }),
              Success: () => {
                return { target: "Submitted" };
              },
            })
          ),
        onError: {
          target: "EnteringQuantity",
          context: {
            notice:
              "Could not add the meal entry. Check the quantity and try again.",
          },
        },
      },
    },
    Submitted: {},
  },
});

trigger API

Similar to XState Store, also v6 get the trigger API as an alternative to send:

export default function NewPlanScreen() {
  const [snapshot, , actor] = useMachine(newPlanRouteMachine);
  return (
    <MealPlanForm
      onBack={actor.trigger.back}
      onSubmit={(input) => actor.trigger.submit({ input })}
    />
  );
}

alpha is available on npm, and I already migrated a full codebase to it.

Go check it out. Expect many fixes and improvements before a main release ๐Ÿ”œ


I am using v6 to shape the API also of Machine in effect. A lot of work-in-progress ๐Ÿ—๏ธ

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