State machines offer several API’s for expressing state. Like other tools, you can keep arbitrary values in a store (usually expressed as an object) called context
.
This is handy for values which change over time and you need to keep updated, like the value of a form input:
import { createMachine, assign } from "xstate";
const machine = createMachine({
context: {
name: "",
},
on: {
CHANGE_NAME: {
actions: assign((context, event) => {
return {
name: event.value,
};
}),
},
},
});
Every time the CHANGE_NAME
event is sent to the machine, we'll update the value in context
. We can then use that value to display the value in our UI or send it to an API.
XState also gives you another way of expressing state - through finite states. Let's imagine a modal:
const machine = createMachine({
initial: "closed",
states: {
closed: {
on: {
OPEN: "open",
},
},
open: {
on: {
CLOSE: "close",
},
},
},
});
Here, the modal's state is expressed through the states: {}
attribute, which also defines which events can be received during each state. You can only CLOSE
the modal when it's open
, and vice versa.
Which should I choose?​
The choice between using context
and states
isn't always clear. For instance, the modal machine above could be expressed using context
:
const machine = createMachine({
context: {
isOpen: false,
},
on: {
OPEN: {
actions: assign({ isOpen: true }),
},
CLOSE: {
actions: assign({ isOpen: false }),
},
},
});
This gives you exactly the same functionality as the states-based one above - you can track when the modal is open and closed, and send the same events.
The reason this can be expressed using both states
and context
is because all of the events do the same thing no matter what state you’re in. There are no events you need to declare as impossible in certain states.
To show you what I mean, let’s imagine a form input inside a modal. We only want to allow changes to the form input while the modal is open.
const machine = createMachine({
initial: "closed",
context: {
name: "",
},
states: {
closed: {
on: {
OPEN: "open",
},
},
open: {
on: {
CLOSE: "close",
CHANGE_NAME: {
actions: assign((context, event) => {
return {
name: event.value,
};
}),
},
},
},
},
});
When the modal is in the closed
state, the CHANGE_NAME
event will not change the value in context
. State machines are great at this - only allowing the things you want to happen to happen. Some other examples might be:
- Not allowing users to submit a form while the previous API call is loading
- Only allowing users to log in if they’re not already logged in
Putting things in context​
You might be wondering - but, I can express the above in context
!
const machine = createMachine({
context: {
name: "",
isOpen: false,
},
on: {
OPEN: { actions: assign({ isOpen: true }) },
CLOSE: { actions: assign({ isOpen: false }) },
CHANGE_NAME: {
actions: assign((context, event) => {
// This acts as the guard to prevent editing
// the name while it's open
if (!context.isOpen) return {};
return {
name: event.value,
};
}),
},
},
});
I think this is incorrect for two reasons. First, as requirements grow, so will the complexity of your logic. Let’s imagine that the modal can now be either closing
(i.e. animating out) or closed
. We’ll soon see an explosion of booleans, as I discussed in this article on useState/useReducer.
Second, XState is auto-documenting via the XState visualiser. The more your logic is expressed in states
, the easier it’s going to be to visualise. The machine above is basically a single state with its logic expressed in ways that XState can’t visualise.
Rules to live by​
You should be keeping most of your state in context. That includes form values, API data - anything which cannot be expressed finitely.
But state machines are powerful because of their states. Use states when you want to express your logic visually, or gate events to certain states.