Since in XState states are explicit by default, we don't actually need to change much.
This is our original implementation, notice how the states ("Editing", "Loading", "Complete") are already encoded in the machine.
const machine = setup({
types: {
context: {} as Context,
events: {} as Event,
},
actors: { submitActor },
}).createMachine({
context: initialContext,
initial: "Editing",
states: {
Editing: {
on: {
"update-username": {
actions: assign(({ event }) => ({
username: event.username,
})),
},
submit: { target: "Loading" },
},
},
Loading: {
invoke: {
src: "submitActor",
input: ({ event, context }) => {
assertEvent(event, "submit");
return { event: event.event, context };
},
onDone: { target: "Complete" },
},
},
Complete: {},
},
});
We therefore just need a new "Error" state:
const machine = setup({
types: {
context: {} as Context,
events: {} as Event,
},
actors: { submitActor },
}).createMachine({
context: initialContext,
initial: "Editing",
states: {
Editing: {
on: {
"update-username": {
actions: assign(({ event }) => ({
username: event.username,
})),
},
submit: { target: "Loading" },
},
},
Loading: {
invoke: {
src: "submitActor",
input: ({ event, context }) => {
assertEvent(event, "submit");
return { event: event.event, context };
},
onDone: { target: "Complete" },
},
},
Error: {},
Complete: {},
},
});
When an invoked actor fails, invoke
will trigger onError
and allow us to transition to the "Error" state:
Whereas
onDone
is executed when thePromise
resolves successfully,onError
is executed when thePromise
rejects.
const machine = setup({
types: {
context: {} as Context,
events: {} as Event,
},
actors: { submitActor },
}).createMachine({
context: initialContext,
initial: "Editing",
states: {
Editing: {
on: {
"update-username": {
actions: assign(({ event }) => ({
username: event.username,
})),
},
submit: { target: "Loading" },
},
},
Loading: {
invoke: {
src: "submitActor",
input: ({ event, context }) => {
assertEvent(event, "submit");
return { event: event.event, context };
},
onError: { target: "Error" },
onDone: { target: "Complete" },
},
},
Error: {},
Complete: {},
},
});
Inside "Error" we capture the same events as "Editing" (update-username
and submit
):
const machine = setup({
types: {
context: {} as Context,
events: {} as Event,
},
actors: { submitActor },
}).createMachine({
context: initialContext,
initial: "Editing",
states: {
Editing: {
on: {
"update-username": {
actions: assign(({ event }) => ({
username: event.username,
})),
},
submit: { target: "Loading" },
},
},
Loading: {
invoke: {
src: "submitActor",
input: ({ event, context }) => {
assertEvent(event, "submit");
return { event: event.event, context };
},
onError: { target: "Error" },
onDone: { target: "Complete" },
},
},
Error: {
on: {
"update-username": {
actions: assign(({ event }) => ({
username: event.username,
})),
},
submit: { target: "Loading" },
},
},
Complete: {},
},
});
With this we have all the states in place to implement the UI:
export default function Machine() {
const [snapshot, send] = useActor(machine);
if (snapshot.matches("Complete")) {
return <p>Done</p>;
}
return (
<form onSubmit={(event) => send({ type: "submit", event })}>
<input
type="text"
value={snapshot.context.username}
onChange={(e) =>
send({ type: "update-username", username: e.target.value })
}
/>
<button type="submit" disabled={snapshot.matches("Loading")}>
Confirm
</button>
{snapshot.matches("Error") && <p>Error occurred</p>}
</form>
);
}
Notice how the states we defined with useState
and useReducer
are the same encoded in the machine:
// 👇 `useState` and `useReducer` states
type State = "Editing" | "Loading" | "Error" | "Complete";
// 👇 Machine states
const machine = setup({
/// ...
}).createMachine({
context: initialContext,
initial: "Editing",
states: {
Editing: { /** ... */ },
Loading: { /** ... */ },
Error: { /** ... */ },
Complete: {},
},
});
Some observations from the new implementations:
- We needed to make states explicit. This is still a rather simple flow, so we are still able to read and understand the code with
useState
anduseReducer
. However, as the logic grows in complexity, state transitions will become harder to understand and maintain, since they are scattered in different places. With XState state is encoded in the machine since the beginning.
useState
anduseReducer
required to restructure the code, while with XState is only a matter of adding a new state.- XState allows to handle even more complex scenarios (parallel states, hierarchical states, validation, and more). As new requirements are added, the logic will always be contained inside the machine. This cannot be said for
useState
anduseReducer
. Their logic will become even more scattered and harder to understand.
We start to see the benefits of XState way more clearly now, as the logic grows in complexity.
That's one of the reasons why XState is hard for beginners: simple examples and use cases are not ideal to convey the benefits.
Initially it may seem that we are only making the code more complex and verbose, but the real benefits are in the clarity of the logic.
As the logic evolves, with XState we can keep the code bug free and maintainable. We are going to appreciate this even more in the next modules.