Form fields actors

Actors allow to isolate the state and logic of each part of the app, to then reuse and combine them to build complex behaviors.

An example is an actor to manage the state of a form field. A common approach in XState without actors would be to add each field to context and multiple events to update their state:

const machine = createMachine({
  types: {
    context: {} as {
      // 👇 Add each field to context
      firstName: string;
      lastName: string;
    },
    events: {} as
      // 👇 Add multiple events to update each field state
      | { type: "first-name.update"; value: string }
      | { type: "last-name.update"; value?: string },
  },
  context: { firstName: "", lastName: "" },
  initial: "Editing",
  states: {
    Editing: {
      on: {
        // 👇 Repeat the same `assign` action for each field
        "first-name.update": {
          actions: assign(({ event }) => ({ firstName: event.value })),
        },
        "last-name.update": {
          actions: assign(({ event }) => ({ lastName: event.value })),
        },
      },
    },
  },
});

This approach is not very scalable and verbose. We are repeating the same code for each field (context, events, assign).

With actors instead we can isolate the state and logic of a field, and compose multiple of them to handle the form state.

Text field actor

XState offers multiple ways to create actors. In this example, since we only need a way to store and update state, we don't need a complete state machine.

We use fromTransition instead.

If you want to learn more about each type of actor, check out XState: Complete Getting Started Guide.

fromTransition works like a reducer function, updating the state based on the event type.

We start by defining the context of the actor (a single value property):

interface Context {
  value: string;
}

Then we define the events that the actor can handle:

type Event =
  | { type: "update"; value: string }
  | { type: "reset"; value?: string };

We can now define the actor using fromTransition with the Context and Event types.

The second argument of fromTransition allows to define the initial context.

In our case, we create a function that accepts an optional input to set the initial value.

interface Context {
  value: string;
}

type Event =
  | { type: "update"; value: string }
  | { type: "reset"; value?: string };

export const textFieldActor = fromTransition(
  // 👇 Typed context and events
  (_: Context, event: Event): Context => // ...

  ({ input }: { input?: { initialValue?: string } }) => ({
    // 👇 Optional initial value (or default to empty string)
    value: input?.initialValue ?? "",
  })
);

Match each event

The Match module of effect brings pattern matching to TypeScript.

We use it to match each event and update the state based on the event type:

export const textFieldActor = fromTransition(
  (_: Context, event: Event): Context =>
    Match.value(event).pipe(
      Match.when({ type: "update" }, (event) => ({ value: event.value })),
      Match.when({ type: "reset" }, (event) => ({ value: event.value ?? "" })),

      // 👇 Exhaustive pattern matching for all events
      Match.exhaustive
    ),
  ({ input }: { input?: { initialValue?: string } }) => ({
    value: input?.initialValue ?? "",
  })
);

With this the actor is completed. We use the same pattern to create a numberFieldActor and a optionalNumberFieldActor:

export const numberFieldActor = fromTransition(
  (_: Context, event: Event): Context =>
    Match.value(event).pipe(
      Match.when({ type: "update" }, (event) => ({
        // 👇 `* 10` to handle decimal numbers as integers
        value: Number.isNaN(event.value) ? 0 : event.value * 10,
      })),
      Match.when({ type: "reset" }, (event) => ({
        value: event.value ?? 0,
      })),
      Match.exhaustive
    ),
  ({ input }: { input?: { initialValue?: number } }) => ({
    value: input?.initialValue !== undefined ? input.initialValue * 10 : 0,
  })
);
export const optionalNumberFieldActor = fromTransition(
  (_: Context, event: Event): Context =>
    Match.value(event).pipe(
      Match.when({ type: "update" }, (event) => ({
        value:
          Number.isNaN(event.value) || event.value === 0
            ? undefined
            : event.value * 10,
      })),
      Match.when({ type: "reset" }, (event) => ({
        value: event.value ?? 0,
      })),
      Match.exhaustive
    ),
  ({ input }: { input?: { initialValue?: number } }) => ({
    value:
      input?.initialValue !== undefined ? input.initialValue * 10 : undefined,
  })
);

Combining actors using spawn

Instead of defining each field as a primitive value, we use ActorRefFrom to type them as actors:

const machine = createMachine({
  types: {
    context: {} as {
      firstName: ActorRefFrom<typeof textFieldActor>;
      lastName: ActorRefFrom<typeof textFieldActor>;
    },
  },
  // ...
});

When initializing the machine we can use spawn inside context to create each actor:

const machine = createMachine({
  types: {
    context: {} as {
      firstName: ActorRefFrom<typeof textFieldActor>;
      lastName: ActorRefFrom<typeof textFieldActor>;
    },
  },
  context: ({ spawn }) => ({
    firstName: spawn(textFieldActor),
    lastName: spawn(textFieldActor),
  }),
  // ...
});

With this the logic to update the state of each field is isolated and reusable.

We don't need to define other events or assign actions 🪄

const machine = createMachine({
  types: {
    context: {} as {
      firstName: ActorRefFrom<typeof textFieldActor>;
      lastName: ActorRefFrom<typeof textFieldActor>;
    },
  },
  context: ({ spawn }) => ({
    firstName: spawn(textFieldActor),
    lastName: spawn(textFieldActor),
  }),
  initial: "Editing",
  states: {
    Editing: {},
  },
});

When we want to extract the state of each field we can access the current snapshot from the actor:

const machine = createMachine({
  types: {
    context: {} as {
      firstName: ActorRefFrom<typeof textFieldActor>;
      lastName: ActorRefFrom<typeof textFieldActor>;
    },
  },
  context: ({ spawn }) => ({
    firstName: spawn(textFieldActor),
    lastName: spawn(textFieldActor),
  }),
  initial: "Editing",
  states: {
    Editing: {
      on: {
        print: {
          actions: ({ context }) => {
            console.log(context.firstName.getSnapshot().context.value);
            console.log(context.lastName.getSnapshot().context.value);
          },
        },
      },
    },
  },
});

It's easy to extend the logic of each actor by updating a single actor. The changes will be reflected everywhere in the app (with a full type-safe API).