We’ve recently started using XState at work for separating business logic from the UI.
Modeling UI interactions with it which previously relied on bits and pieces of scattered state is now a lot simpler.
Consider a simple interaction for an element with the following spec:
- On initial page load, the element is visible
- After 3 seconds, the element disappears
- Any time after that, if the element is hovered by the user, we show the element
- When hover ends, we again delay hiding it for 3 seconds, similar to initial load
Breakdown
A nice little interaction for showing that extra controls exist but keeping them out of view. Based on the problem, there are certain events after which the element is shown:
- Initially on page load
- When hovered
- The 3 second delay after hover exit
And certain events after which the element is hidden:
- 3 seconds after initial page load if it wasn’t hovered again
- 3 seconds after hover exit
With this, we can define a few states and their related events (this will be the outline of our XState state machine!)
initial
- on hover enter -> show
- after 3s -> hide
show
- on hover enter -> stay in show
- on hover exit -> delay
delay
- on hover enter -> show
- after 3s -> hide
hide
- on hover enter -> show
How will our machine look like?
import { setup } from "xstate";
const Tags = {
SHOULD_SHOW: "machine.shouldShow",
} as const;
type Events =
| {
type: "HOVER_ENTER";
}
| {
type: "HOVER_LEAVE";
};
const hoverMachine = setup({
actions: {},
types: {
events: {} as Events,
},
}).createMachine({
initial: "initial",
states: {
initial: {
tags: [Tags.SHOULD_SHOW],
on: {
HOVER_LEAVE: {
target: "delay",
},
HOVER_ENTER: {
target: "show",
},
},
after: {
3000: "hide",
},
},
show: {
tags: [Tags.SHOULD_SHOW],
on: {
HOVER_LEAVE: {
target: "delay",
},
HOVER_ENTER: {
target: "show",
},
},
},
delay: {
tags: [Tags.SHOULD_SHOW],
after: {
3000: {
target: "hide",
},
},
on: {
HOVER_ENTER: {
target: "show",
},
},
},
hide: {
on: {
HOVER_ENTER: {
target: "show",
},
},
},
},
});
export { hoverMachine };
This is a simple state machine (which is very similar to our event based description that we made).
State descriptions
There’s an initial state called initial
(hah), which transitions to hide
after 3 seconds in case there is no HOVER_ENTER
/HOVER_LEAVE
event.
If there is a HOVER_ENTER
event, it transitions to show
. From there it can go to delay after HOVER_LEAVE
.
From delay, it can go to hide
after 3 seconds, or back to show
if there is a HOVER_ENTER
event.
Finally from hide
, it can go back to show
if there is a HOVER_ENTER
event.
You’ll notice some of these states are tagged with Tags.SHOULD_SHOW
. This marks those states as having a tag, which we use for the show state in our UI! Remember how we found out which states/events should show the element based on the problem? This tag helps us realise that.
The UI code (it’s React sorry) 🙏
import { useActor } from "@xstate/react";
import { Tags, hoverMachine } from "./machine";
function App() {
const [state, send] = useActor(hoverMachine);
const shouldShow = state.hasTag(Tags.SHOULD_SHOW);
return (
<>
<main
className="..."
onPointerEnter={() => {
send({ type: "HOVER_ENTER" });
}}
onPointerLeave={() => {
send({ type: "HOVER_LEAVE" });
}}
>
<div className="...">
<h1 className="...">
Should show element: {shouldShow ? "yes" : "no"}
</h1>
<span>State machine state: {state.value}</span>
</div>
</main>
</>
);
}
The best part about state machines, you’ve avoided a lot of if-else soup. No need to wrestle with timers and their ids in UI. (No useEffect
as well 👀)
You don’t need to worry about your UI logic leaking into your business logic. You can completely decouple the two.
Sandbox
References:
Feel free to contact me on BlueSky for any questions or feedback, or if you find any mistakes in this. I’d love to hear from you!