Build state machine with actors

State machines are used in the app to manage request to update the local database.

A state machine is required to handle asynchronous requests to the database.

Since the Pglite API is asynchronous, we need to define loading states and take care of possible errors.

Other solutions provide a synchronous API, making this step unnecessary (e.g. LiveStore)

Let's see an example of state machine to manage a meal plan (update, remove, set as current).

Combining form fields actors

The first step when defining a machine is to provide context and events types.

As we saw in the previous lesson, we combine actors inside context to handle the state of each field (numberFieldActor):

export const machine = setup({
  types: {
    context: {} as {
      calories: ActorRefFrom<typeof numberFieldActor>;
      fatsRatio: ActorRefFrom<typeof numberFieldActor>;
      carbohydratesRatio: ActorRefFrom<typeof numberFieldActor>;
      proteinsRatio: ActorRefFrom<typeof numberFieldActor>;
    },
    events: {} as
      | { type: "plan.update"; id: number }
      | { type: "plan.remove"; id: number }
      | { type: "plan.set"; id: number },
  },
}).createMachine({
  id: "manage-serving",
  initial: "Idle",
  states: {
    Idle: {},
  },
});

Since the machine can be used to update an existing plan, we add an input that allows to set the initial value of each field:

export const machine = setup({
  types: {
    input: {} as {
      calories: number;
      fatsRatio: number;
      carbohydratesRatio: number;
      proteinsRatio: number;
    },
    context: {} as {
      calories: ActorRefFrom<typeof numberFieldActor>;
      fatsRatio: ActorRefFrom<typeof numberFieldActor>;
      carbohydratesRatio: ActorRefFrom<typeof numberFieldActor>;
      proteinsRatio: ActorRefFrom<typeof numberFieldActor>;
    },
    events: {} as
      | { type: "plan.update"; id: number }
      | { type: "plan.remove"; id: number }
      | { type: "plan.set"; id: number },
  },
}).createMachine({
  id: "manage-serving",
  initial: "Idle",
  states: {
    Idle: {},
  },
});

When we then spawn each actor inside context we can use input to set initialValue:

export const machine = setup({
  types: {
    input: {} as {
      calories: number;
      fatsRatio: number;
      carbohydratesRatio: number;
      proteinsRatio: number;
    },
    context: {} as {
      calories: ActorRefFrom<typeof numberFieldActor>;
      fatsRatio: ActorRefFrom<typeof numberFieldActor>;
      carbohydratesRatio: ActorRefFrom<typeof numberFieldActor>;
      proteinsRatio: ActorRefFrom<typeof numberFieldActor>;
    },
    events: {} as
      | { type: "plan.update"; id: number }
      | { type: "plan.remove"; id: number }
      | { type: "plan.set"; id: number },
  },
}).createMachine({
  id: "manage-serving",
  context: ({ spawn, input }) => ({
    calories: spawn(numberFieldActor, {
      input: { initialValue: input.calories },
    }),
    carbohydratesRatio: spawn(numberFieldActor, {
      input: { initialValue: input.carbohydratesRatio },
    }),
    fatsRatio: spawn(numberFieldActor, {
      input: { initialValue: input.fatsRatio },
    }),
    proteinsRatio: spawn(numberFieldActor, {
      input: { initialValue: input.proteinsRatio },
    }),
  }),
  initial: "Idle",
  states: {
    Idle: {},
  },
});

Executing requests using actors

For each event we make a request to the database. Since each request is asynchronous, we need to invoke separate actors using fromPromise:

export const machine = setup({
  types: {
    input: {} as {
      calories: number;
      fatsRatio: number;
      carbohydratesRatio: number;
      proteinsRatio: number;
    },
    context: {} as {
      calories: ActorRefFrom<typeof numberFieldActor>;
      fatsRatio: ActorRefFrom<typeof numberFieldActor>;
      carbohydratesRatio: ActorRefFrom<typeof numberFieldActor>;
      proteinsRatio: ActorRefFrom<typeof numberFieldActor>;
    },
    events: {} as
      | { type: "plan.update"; id: number }
      | { type: "plan.remove"; id: number }
      | { type: "plan.set"; id: number },
  },
  actors: {
    updatePlan: fromPromise(
      // ...
    ),
    removePlan: fromPromise(
      // ...
    ),
    setAsCurrentPlan: fromPromise(
      // ...
    ),
  },
}).createMachine({
  id: "manage-serving",
  context: ({ spawn, input }) => ({
    calories: spawn(numberFieldActor, {
      input: { initialValue: input.calories },
    }),
    carbohydratesRatio: spawn(numberFieldActor, {
      input: { initialValue: input.carbohydratesRatio },
    }),
    fatsRatio: spawn(numberFieldActor, {
      input: { initialValue: input.fatsRatio },
    }),
    proteinsRatio: spawn(numberFieldActor, {
      input: { initialValue: input.proteinsRatio },
    }),
  }),
  initial: "Idle",
  states: {
    Idle: {},
  },
});

Each event will transition to a new state that executes each request actor:

export const machine = setup({
  types: {
    input: {} as {
      calories: number;
      fatsRatio: number;
      carbohydratesRatio: number;
      proteinsRatio: number;
    },
    context: {} as {
      calories: ActorRefFrom<typeof numberFieldActor>;
      fatsRatio: ActorRefFrom<typeof numberFieldActor>;
      carbohydratesRatio: ActorRefFrom<typeof numberFieldActor>;
      proteinsRatio: ActorRefFrom<typeof numberFieldActor>;
    },
    events: {} as
      | { type: "plan.update"; id: number }
      | { type: "plan.remove"; id: number }
      | { type: "plan.set"; id: number },
  },
  actors: {
    updatePlan: fromPromise(
      // ...
    ),
    removePlan: fromPromise(
      // ...
    ),
    setAsCurrentPlan: fromPromise(
      // ...
    ),
  },
}).createMachine({
  id: "manage-serving",
  context: ({ spawn, input }) => ({
    calories: spawn(numberFieldActor, {
      input: { initialValue: input.calories },
    }),
    carbohydratesRatio: spawn(numberFieldActor, {
      input: { initialValue: input.carbohydratesRatio },
    }),
    fatsRatio: spawn(numberFieldActor, {
      input: { initialValue: input.fatsRatio },
    }),
    proteinsRatio: spawn(numberFieldActor, {
      input: { initialValue: input.proteinsRatio },
    }),
  }),
  initial: "Idle",
  states: {
    Idle: {
      on: {
        "plan.update": {
          target: "Updating",
        },
        "plan.remove": {
          target: "Removing",
        },
        "plan.set": {
          target: "Setting",
        },
      },
    },
    Updating: {
      invoke: {
        src: "updatePlan",
        // ...
      },
    },
    Removing: {
      invoke: {
        src: "removePlan",
        // ...
      },
    },
    Setting: {
      invoke: {
        src: "setAsCurrentPlan",
        // ...
      },
    },
  },
});

All the state logic is encapsulated inside the machine. The component will be only responsible for rendering the UI and sending events.