This post is an introduction to XState as it might be used in a Svelte project. XState is unique in the JavaScript ecosystem. It doesn’t keep your DOM synced with your application state, nor does it help you with asynchrony, or streams of data; XState helps manage your application’s state by allowing you to model your state as a finite state machine (FSM).
A deep dive into state machines and formal languages is beyond the scope of this post, but Jon Bellah does that in another CSS-Tricks article. For now, think of an FSM as a flow chart. Flow charts have a number of states, represented as bubbles, and arrows leading from one state to the next, signifying a transition from one state to the next. State machines can have more than one arrow leading out of a state, or none at all if it’s a final state, and they can even have arrows leaving a state, and pointing right back into that same state.
If that all sounds overwhelming, relax, we’ll get into all the details, nice and slow. For now, the high level view is that, when we model our application as a state machine, we’ll be creating different “states” our application can be in (get it … state machine … states?), and the events that happen and cause changes to state will be the arrows between those states. XState calls the states “states,” and the arrows between the states “actions.”
Our example
XState has a learning curve, which makes it challenging to teach. With too contrived a use case it’ll appear needlessly complex. It’s only when an application’s code gets a bit tangled that XState shines. This makes writing about it tricky. With that said, the example we’ll look at is an autocomplete widget (sometimes called autosuggest), or an input box that, when clicked, reveals a list of items to choose from, which filter as you type in the input.
For this post we’ll look at getting the animation code cleaned up. Here’s the starting point:
This is actual code from my svelte-helpers library, though with unnecessary pieces removed for this post. You can click the input and filter the items, but you won’t be able to select anything, “arrow down” through the items, hover, etc. I’ve removed all the code that’s irrelevant to this post.
We’ll be looking at the animation of the list of items. When you click the input, and the results list first renders, we want to animate it down. As you type and filter, changes to the list’s dimensions will animate larger and smaller. And when the input loses focus, or you click ESC
, we animate the list’s height to zero, while fading it out, and then remove it from the DOM (and not before). To make things more interesting (and nice for the user), let’s use a different spring configuration for the opening than what we use for the closing, so the list closes a bit more quickly, or stiffly, so unneeded UX doesn’t linger on the screen too long.
If you’re wondering why I’m not using Svelte transitions to manage the animations in and out of the DOM, it’s because I’m also animating the list’s dimensions when it’s open, as the user filters, and coordinating between transition, and regular spring animations is a lot harder than simply waiting for a spring update to finish getting to zero before removing an element from the DOM. For example, what happens if the user quickly types and filters the list, as it’s animating in? As we’ll see, XState makes tricky state transitions like this easy.
Scoping the Problem
Let’s take a look at the code from the example so far. We’ve got an open
variable to control when the list is open, and a resultsListVisible
property to control whether it should be in the DOM. We also have a closing
variable that controls whether the list is in the process of closing.
On line 28, there’s an inputEngaged
method that runs when the input is clicked or focused. For now let’s just note that it sets open
and resultsListVisible
to true. inputChanged
is called when the user types in the input, and sets open
to true. This is for when the input is focused, the user clicks escape to close it, but then starts typing, so it can re-open. And, of course, the inputBlurred
function runs when you’d expect, and sets closing
to true, and open
to false.
Let’s pick apart this tangled mess and see how the animations work. Note the slideInSpring
and opacitySpring
at the top. The former slides the list up and down, and adjusts the size as the user types. The latter fades the list out when hidden. We’ll focus mostly on the slideInSpring
.
Take a look at the monstrosity of a function called setSpringDimensions
. This updates our slide spring. Focusing on the important pieces, we take a few boolean properties. If the list is opening, we set the opening spring config, we immediately set the list’s width (I want the list to only slide down, not down and out), via the { hard: true }
config, and then set the height. If we’re closing, we animate to zero, and, when the animation is complete, we set resultsListVisible
to false (if the closing animation is interrupted, Svelte will be smart enough to not resolve the promise so the callback will never run). Lastly, this method is also called any time the size of the results list changes, i.e., as the user filters. We set up a ResizeObserver
elsewhere to manage this.
Spaghetti galore
Let’s take stock of this code.
- We have our
open
variable which tracks if the list is open. - We have the
resultsListVisible
variable which tracks if the list should be in the DOM (and set to false after the close animation is complete). - We have the
closing
variable that tracks if the list is in the process of closing, which we check for in the input focus/click handler so we can reverse the closing animation if the user quickly re-engages the widget before it’s done closing. - We also have
setSpringDimensions
that we call in four different places. It sets our springs depending on whether the list is opening, closing, or just resizing while open (i.e. if the user filters the list). - Lastly, we have a
resultsListRendered
Svelte action that runs when the results list DOM element renders. It starts up ourResizeObserver
, and when the DOM node unmounts, setsclosing
to false.
Did you catch the bug? When the ESC
button is pressed, I’m only setting open
to false
. I forgot to set closing to true
, and call setSpringDimensions(false, true)
. This bug was not purposefully contrived for this blog post! That’s an actual mistake I made when I was overhauling this widget’s animations. I could just copy paste the code in inputBlured
over to where the escape button is caught, or even move it to a new function and call it from both places. This bug isn’t fundamentally hard to solve, but it does increase the cognitive load of the code.
There’s a lot of things we’re keeping track of, but worst of all, this state is scattered all throughout the module. Take any piece of state described above, and use CodeSandbox’s Find feature to view all the places where that piece of state is used. You’ll see your cursor bouncing across the file. Now imagine you’re new to this code, trying to make sense of it. Think about the growing mental model of all these state pieces that you’ll have to keep track of, figuring out how it works based on all the places it exists. We’ve all been there; it sucks. XState offers a better way; let’s see how.
Introducing XState
Let’s step back a bit. Wouldn’t it be simpler to model our widget in terms of what state it’s in, with events happening as the user interacts, which cause side effects, and transitions to new states? Of course, but that’s what we were already doing; the problem is, the code is scattered everywhere. XState gives us the ability to properly model our state in this way.
Setting expectations
Don’t expect XState to magically make all of our complexity vanish. We still need to coordinate our springs, adjust the spring’s config based on opening and closing states, handle resizes, etc. What XState gives us is the ability to centralize this state management code in a way that’s easy to reason about, and adjust. In fact, our overall line count will increase a bit, as a result of our state machine setup. Let’s take a look.
Your first state machine
Let’s jump right in, and see what a bare bones state machine looks like. I’m using XState’s FSM package, which is a minimal, pared down version of XState, with a tiny 1KB bundle size, perfect for libraries (like an autosuggest widget). It doesn’t have a lot of advanced features like the full XState package, but we wouldn’t need them for our use case, and we wouldn’t want them for an introductory post like this.
The code for our state machine is below, and the interactive demo is over at Code Sandbox. There’s a lot, but we’ll go over it shortly. And to be clear, it doesn’t work yet.
const stateMachine = createMachine(
{
initial: "initial",
context: {
open: false,
node: null
},
states: {
initial: {
on: { OPEN: "open" }
},
open: {
on: {
RENDERED: { actions: "rendered" },
RESIZE: { actions: "resize" },
CLOSE: "closing"
},
entry: "opened"
},
closing: {
on: {
OPEN: { target: "open", actions: ["resize"] },
CLOSED: "closed"
},
entry: "close"
},
closed: {
on: {
OPEN: "open"
},
entry: "closed"
}
}
},
{
actions: {
opened: assign(context => {
return { ...context, open: true };
}),
rendered: assign((context, evt) => {
const { node } = evt;
return { ...context, node };
}),
close() {},
resize(context) {},
closed: assign(() => {
return { open: false, node: null };
})
}
}
);
Let’s go from top to bottom. The initial
property controls what the initial state is, which I’ve called “initial.” context
is the data associated with our state machine. I’m storing a boolean for whether the results list is currently open, as well as a node
object for that same results list. Next we see our states. Each state is a key in the states
property. For most states, you can see we have an on
property, and an entry
property.
on
configures events. For each event, we can transition to a new state; we can run side effects, called actions; or both. For example, when the OPEN
event happens inside of the initial
state, we move into the open
state. When the RENDERED
event happens in the open
state, we run the rendered
action. And when the OPEN
event happens inside the closing
state, we transition into the open
state, and also run the resize action. The entry
field you see on most states configures an action to run automatically whenever a state is entered. There are also exit
actions, although we don’t need them here.
We still have a few more things to cover. Let’s look at how our state machine’s data, or context, can change. When we want an action to modify context, we wrap it in assign
and return the new context from our action; if we don’t need any processing, we can just pass the new state directly to assign
. If our action does not update context, i.e., it’s just for side effects, then we don’t wrap our action function in assign
, and just perform whatever side effects we need.
Affecting change in our state machine
We have a cool model for our state machine, but how do we run it? We use the interpret
function.
const stateMachineService = interpret(stateMachine).start();
Now stateMachineService
is our running state machine, on which we can invoke events to force our transitions and actions. To fire an event, we call send
, passing the event name, and then, optionally, the event object. For example, in our Svelte action that runs when the results list first mounts in the DOM, we have this:
stateMachineService.send({ type: "RENDERED", node });
That’s how the rendered action gets the node for the results list. If you look around the rest of the AutoComplete.svelte
file, you’ll see all the ad hoc state management code replaced with single line event dispatches. In the event handler for our input click/focus, we run the OPEN
event. Our ResizeObserver fires the RESIZE
event. And so on.
Let’s pause for a moment and appreciate the things XState gives us for free here. Let’s look at the handler that runs when our input is clicked or focused before we added XState.
function inputEngaged(evt) {
if (closing) {
setSpringDimensions();
}
open = true;
resultsListVisible = true;
}
Before, we were checking to see if we were closing, and if so, forcing a re-calculation of our sliding spring. Otherwise we opened our widget. But what happened if we clicked on the input when it was already open? The same code re-ran. Fortunately that didn’t really matter. Svelte doesn’t care if we re-set open
and resultsListVisible
to the values they already held. But those concerns disappear with XState. The new version looks like this:
function inputEngaged(evt) {
stateMachineService.send("OPEN");
}
If our state machine is already in the open state, and we fire the OPEN
event, then nothing happens, since there’s no OPEN
event configured for that state. And that special handling for when the input is clicked when the results are closing? That’s also handled right in the state machine config — notice how the OPEN
event tacks on the resize
action when it’s run from the closing
state.
And, of course, we’ve fixed the ESC
key bug from before. Now, pressing the key simply fires the CLOSE
event, and that’s that.
Finishing up
The ending is almost anti-climactic. We need to take all of the work we were doing before, and simply move it to the right place among our actions. XState does not remove the need for us to write code; it only provides a structured, clear place to put it.
{
actions: {
opened: assign({ open: true }),
rendered: assign((context, evt) => {
const { node } = evt;
const dimensions = getResultsListDimensions(node);
itemsHeightObserver.observe(node);
opacitySpring.set(1, { hard: true });
Object.assign(slideInSpring, SLIDE_OPEN);
slideInSpring.update(prev => ({ ...prev, width: dimensions.width }), {
hard: true
});
slideInSpring.set(dimensions, { hard: false });
return { ...context, node };
}),
close() {
opacitySpring.set(0);
Object.assign(slideInSpring, SLIDE_CLOSE);
slideInSpring
.update(prev => ({ ...prev, height: 0 }))
.then(() => {
stateMachineService.send("CLOSED");
});
},
resize(context) {
opacitySpring.set(1);
slideInSpring.set(getResultsListDimensions(context.node));
},
closed: assign(() => {
itemsHeightObserver.unobserve(resultsList);
return { open: false, node: null };
})
}
}
Odds and ends
Our animation state is in our state machine, but how do we get it out? We need the open
state to control our results list rendering, and, while not used in this demo, the real version of this autosuggest widget needs the results list DOM node for things like scrolling the currently highlighted item into view.
It turns out our stateMachineService
has a subscribe
method that fires whenever there’s a state change. The callback you pass is invoked with the current state machine state, which includes a context
object. But Svelte has a special trick up its sleeve: its reactive syntax of $:
doesn’t only work with component variables and Svelte stores; it also works with any object with a subscribe
method. That means we can sync with our state machine with something as simple as this:
$: ({ open, node: resultsList } = $stateMachineService.context);
Just a regular destructuring, with some parens to help things get parsed correctly.
One quick note here, as an area for improvement. Right now, we have some actions which both both perform a side effect, and also update state. Ideally, we should probably split these up into two actions, one just for the side effect, and the other using assign
for the new state. But I decided to keep things as simple as possible for this article to help ease the introduction of XState, even if a few things wound up not being quite ideal.
Parting thoughts
I hope this post has sparked some interest in XState. I’ve found it to be an incredibly useful, easy to use tool for managing complex state. Please know that we’ve only scratched the surface. We focused on the minimal fsm package, but the entire XState library is capable of a lot more than what we covered here, from nested states, to first-class support for Promises, and it even has a state visualization tool! I urge you to check it out.
Happy coding!
The post Coordinating Svelte Animations With XState appeared first on CSS-Tricks.
You can support CSS-Tricks by being an MVP Supporter.
source https://css-tricks.com/coordinating-svelte-animations-with-xstate/
No comments:
Post a Comment