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.