Statecharts are the ideal model for state management on the frontend 💯
I have been using statecharts (xstate
) for a while now. These are some of the most common patterns I use, in production 👇
Disclaimer: State gets complex
"xstate
is overenginerring". "Statecharts are too complex". "useState
is fine".
The most common critiques of xstate
don't take in consideration the nature of state:
State has a tendency to get exponentially more and more complex with each feature 🤯
State that may seem "fine" using useState
will eventually force one of two choices:
- Keep using
useState
and manually manage more and more complexity - Refactoring state to use more advanced techniques/libraries
Option 1 leads to more and more tech debt, errors, and slowness (both performance and development time).
Option 2 is expensive for the business, since refactoring means stop shipping features.
Therefore, I lean for a third option:
Always implement state using statecharts from the start (
xstate
) 🙌
No matter how simple the state, I use xstate
. It's always the case that the state gets more complex, and I am already covered by the benefits of statecharts.
Note: It also depends on the kind of state.
Read here for more details: All kinds of state management in React
Single actor/machine
For simple flows, I implement a single machine directly on top of the component, in the same file:
import { useMachine } from "@xstate/react";
import { setup } from "xstate";
const machine = setup({
types: {},
actors: {},
}).createMachine({
// ...
});
export default function LoginEmailForm() {
const [snapshot, send] = useMachine(machine);
return (
// ...
)
}
I don't need to jump between multiple files. machine
is not exported, but specific for that single component.
If the component is not used anymore, you can confidently remove the machine as well 💁🏼♂️
Reuse logic
Some machines are more generic. An example is this copy-text machine:
import { setup } from "xstate";
export const machine = setup({
types: {
events: {} as { type: "copy"; text: string },
},
}).createMachine({
initial: "Idle",
states: {
Idle: {
on: {
copy: {
target: "Copied",
actions: ({ event }) => {
window.navigator.clipboard.writeText(event.text);
},
},
},
},
Copied: {
after: {
2000: "Idle",
},
},
},
});
Since copying text is common in multiple pages, I implement this machine in isolation. I then export and share it (in a monorepo for example):
import * as CopyTextMachine from "./copy-text";
import * as UploadFileMachine from "./upload-file";
export { CopyTextMachine, UploadFileMachine };
With xstate
you can compose logic using actors. There are multiple way of doing this (check out Patterns for state management with actors in React with XState for more details):
const hubMachine = setup().createMachine({ /* ... */ });
const blogMachine = setup().createMachine({ /* ... */ });
export const machine = setup({
types: {
children: {} as {
hub: "hubMachine";
blog: "blogMachine";
},
},
actors: { hubMachine, blogMachine },
}).createMachine({
// ...
});
Complex flow
When the user flow becomes even more complex (think multiple steps, each with validation and context), then I split the logic in smaller machines:
- "Main" machine manages flow steps
- Each step of the flow is isolated in a different machine (actor)
const step1Machine = setup({}).createMachine({ /* ... */ });
const step2Machine = setup({}).createMachine({ /* ... */ });
const step3Machine = setup({}).createMachine({ /* ... */ });
export const machine = setup({
types: {
events: {} as { type: "next" },
children: {} as {
step1: "step1Machine";
step2: "step2Machine";
step3: "step3Machine";
},
},
actors: {
step1Machine,
step2Machine,
step3Machine
},
}).createMachine({
initial: "Step1",
states: {
Step1: {
on: { "next": "Step2" },
},
Step2: {
on: { "next": "Step3" },
},
Step3: {},
},
});
Each sub-machine can be implemented in isolation. Only the final "main" machine
is exported.
This strategy reduces complexity for each single machine. The "main" machine is then responsible to coordinate the flow ✋
Bonus: Validation with guards
Validation is often required for state management. I use guards
+ effect
(Schema
):
export const machine = setup({
guards: {
isValid: ({ context }) =>
Either.isRight(
Schema.decodeEither(
Schema.Struct({
displayName: Schema.NonEmptyString,
bio: Schema.NonEmptyString.pipe(Schema.maxLength(MAX_BIO_LENGTH)),
})
)({
displayName: context.displayName,
bio: context.bio,
})
),
},
}).createMachine({
on: {
"continue": {
guard: "isValid",
},
}
});
Schema
gives you any sort of filter and refinement. You then use can
inside the component:
export function CreateHubForm({
actor,
}: {
actor: ActorRefFrom<typeof machine>;
}) {
const isValid = useSelector(
actor,
(snapshot) => snapshot.can({ type: "continue" })
);
return (
<button disabled={!isValid}>Continue</button>
);
}
effect
keeps trending (it's not by chance, and it's not "just" a trend):
Reading the first page of @SandroMaglione’s guide for Effect appeals more to me than the official docs (no diss, just opinion 🙏). 1. tells me the problem which i can relate 2. gives heads up on the catch
Once again, check out my Effect: Beginners Complete Getting Started if you are new to effect
🤝
See you next 👋