The first article in this series discussed the core differences between the reducer and finite state machine models. In this article, we will dig into the fundamental concepts of XState using the React framework. We'll cover states, events, actions, and guards — providing a solid foundation for building programs with XState.
XState is a robust state management library that leverages the simplicity and power of finite state machines to build efficient, scalable JavaScript or TypeScript applications.
Before we begin, prior knowledge of JavaScript and React is necessary to grasp the content of this article. Familiarity with Redux or React's useReducer()
hook would be beneficial but is not mandatory. Lastly, we will be using JavaScript throughout this article.
If you wish to code along, you will need XState and @xstate/react in addition to React.
npm i xstate @xstate/react
Now that we’re on the same page, let us learn about state.
State
Reducer model
When we create apps using the reducer pattern, we need to keep all the data in one spot, called the state. For example, if we're making an API call program, it will include the status
, data
, and error
.
const initialState = {
status: "idle",
data: undefined,
error: undefined,
};
These variables will change throughout the program's lifecycle. When the program is triggered, it transitions from idle
to loading
. If the API call succeeds, it assigns the returned value to the data
property and changes the status from loading
to success
. Otherwise, it attaches the returned error message to the error
property and changes the status
from loading
to error
.
const fsmReducer = (state, action) => {
switch (state.status) {
case "idle": {
//code to move to loading state when triggered.
}
case "loading": {
//move to success if the api call is successful. Otherwise, move to error state.
},
case "success" {},
case "error" {},
default: {
return state;
}
}
};
In a usual reducer model, we typically switch based on events, not states. However, in the example mentioned, we're switching based on the state, aligning with the principles of finite state machines.
In summary, the reducer model stores all application variables in a central store, while the finite state machine pattern takes a different approach.
Finite State Machines Model
The FSMs pattern distinguishes clearly between the application's status and other data. In the FSMs model, the program's status is referred to as the state, representing the qualitative aspect, while the remaining data represents the quantitative part of the application.
A program can exist in only one state at any given time and has a finite number of possible states. For instance, a light bulb switch can be either in the on
or off
state.
All other data concerning the application constitutes the quantitative part. For our API call logic, this could be the resolved data from the API call or error message.
Let's redefine the API call program as in the previous example, this time utilizing FSMs with XState.
import { createMachine } from "xstate";
const fetcher = createMachine({
initial: "idle",
context: {
data: undefined,
error: undefined
}
states: {
idle: {
//Some code to execute
},
loading: {
//more code
},
success: {
//more code
},
error: {
//more code
},
},
});
In XState, states are explicitly defined, and all other data is stored in the context object.
The createMachine()
function accepts the application’s logic object as an argument.
You need to specify the initial state of the application, especially if it has multiple states. In this case, its initial state is idle
.
Events
Events are occurrences within the system, which can be triggered by a user action, like clicking a button, or by the program itself, such as resolving or rejecting a promise in JavaScript.
Sending Events
When a user interacts with the UI, like clicking a button or typing, we aim to capture these events and respond by executing some code. XState provides a method to capture these UI events and send them to the state machine for proper handling. Additionally, these events can carry additional dynamic information from the UI.
For example, consider a React counter machine. Whenever the user clicks a button, we want to increment the count by a number provided by the UI, 2 in this case.
import { assign, createMachine } from "xstate";
import { useMachine } from "@xstate/react";
const counterMachine = createMachine({
context: {
count: 0,
},
on: {
ADD: {
//code to handle the Add event
},
},
});
export default function App() {
const [snapshot, send] = useMachine(counterMachine);
return (
<div>
<p>count: {snapshot.context.count}</p>
<button onClick={() => send({ type: "ADD", data: 2 })}>Add two</button>
</div>
);
}
XState provides the useMachine()
React hook, which returns an array containing the snapshot object and the send function. The snapshot object holds essential details about the state machine, including the context object. Meanwhile, the send function allows us to dispatch events from the UI to the machine for appropriate handling.
Next, we would see how events such as ADD
are handled within the machine’s logic.
Handling Events
All state- or program-level events are defined within the on
object.
For single-state programs like the counter example, the on
object is directly placed on the program logic object.
For multi-state applications like the API-call program, the on
object resides within individual states.
It's worth noting that even if an application has multiple states, you can still have an on
object with events directly on the program logic object, similar to the counter example. When such top-level events are dispatched to the machine, they take effect regardless of the current state of the application.
const fetcher = createMachine({
initial: "idle",
context: {
data: undefined,
error: undefined
},
states: {
idle: {
on: {
FETCH: {
target: "loading",
},
},
},
loading: {
on: {
FETCHED: {
target: "success",
},
CANCEL: {
target: "idle",
},
},
},
success: {
on: {
RELOAD: {
target: "loading",
},
},
},
},
});
The idle
state allows the FETCH
event, while the loading
state permits the FETCHED
and CANCEL
events.
Each of these events is assigned an action object. In this case, the action object contains the target
property, specifying the transition or the next state the machine will enter when the event is received.
If the target
is not specified, the program will stay in its current state. Apart from transitions, the action object can also include actions
, which we'll explore next.
Actions
In addition to transitions, actions are also responses to events. actions
can include invoking a function, altering a variable within the context object, or executing asynchronous operations. Next, we’ll learn about each of these common actions in XState.
Changing a Variable
In our counter app example, we utilize the assign action to modify variables within the context object. The syntax is simple and allows us to modify one or multiple variables simultaneously.
const counterMachine = createMachine({
context: {
count: 0,
otherVariable: undefined
},
on: {
ADD: {
actions: assign({
count: ({ context, event }) => context.count + event.data,
otherVariable: ({context, event}) => //new value
}),
},
},
});
Invoking a Function
When an event is dispatched, you might simply want to execute a function, as demonstrated in this case:
const counterMachine = createMachine({
context: {
count: 0,
},
on: {
ADD: {
actions: ({event}) => console.log(event)
},
},
});
The above action will merely log the event. Additionally, you can execute multiple actions sequentially by assigning an array of action functions to the actions property.
const counterMachine = createMachine({
context: {
count: 0,
},
on: {
ADD: {
actions: [({event}) => console.log(event),
assign({
count: ({event}) => event.data
})
]
},
},
});
Performing Asynchronous Operations
XState offers convenient methods to handle asynchronous processes using actors. In this discussion, we'll concentrate on the fromPromise
actor, designed for executing promises, including API calls using fetch()
.
Asynchronous operations via actors have a slight distinction from other actions. While other actions are accessible on the event level within the actions
object, actors (employed for asynchronous tasks in XState) are executed via the invoke
object, which is only available at the state level.
import { assign, createMachine, fromPromise } from "xstate";
import { useMachine } from "@xstate/react";
const counterMachine = createMachine({
initial: "idle",
context: {
data: undefined,
error: undefined,
},
states: {
idle: {
on: {
LOAD_DATA: {
target: "loading",
},
},
},
loading: {
invoke: {
src: fromPromise(() =>
fetch("https://fakestoreapi.com/products/category/jewelery").then(
(res) => res.json()
)
),
onDone: {
target: "success",
actions: assign({
data: ({ event }) => event.output,
}),
},
onError: {
target: "error",
actions: assign({
error: ({ event }) => event.error,
}),
},
},
},
success: {},
error: {},
},
});
export default function App() {
const [snapshot, send] = useMachine(counterMachine);
return (
<div>
<button onClick={() => send({ type: "LOAD_DATA" })}>Load Data</button>
{snapshot.value == "loading" && <div>Loading...</div>}
{snapshot.value == "success" && (
<ul>
{snapshot.context.data.map((item) => (
<li key={item.id}>{item.title}</li>
))}
</ul>
)}
</div>
);
}
When the user clicks on the Load Data
button, it triggers the loading
state, where the invoke
object is executed. We assign the fromPromise()
to the src
property. The fromPromise()
actor requires a callback function that returns a fulfilled or rejected promise. If the promise succeeds, the onDone
event is triggered; otherwise, the onError
event is called.
Guards
If you need to transition to a particular state or execute certain actions based on the value of a variable within your context object, you can achieve this functionality using guards. Guards are functions that evaluate to either true
or false
.
Instead of assigning a single action object with transitions and actions, you attach an array of action objects. Each object in this array will include a guard function along with target
and actions
.
XState executes these objects serially. The first object whose guard evaluates to true will have its target
and actions
executed.
const counterMachine = createMachine({
initial: "editing",
context: {
todo: "",
},
states: {
editing: {
on: {
RECORD_INPUT: {
actions: assign({
todo: ({ event }) => event.data,
}),
},
SAVE: [
{
guard: ({ context }) => context.todo.length > 0,
actions: () => console.log("saved"),
},
{
guard: ({ context }) => context.todo.length == 0,
actions: () => console.log("not saved"),
},
],
},
},
},
});
In this simple todo app, the user is unable to save an empty text or todo.
Conclusion
This article explores the basics of state machines using XState. It covers the fundamentals of states, events, actions, and guards. Each of these concepts has more depth to explore, including parallel states, entry and exit actions, and input and output, all of which you learn more about in the documentation.
XState is a powerful library with comprehensive documentation. Keeping the documentation handy while building your next app with XState will be invaluable.
Top comments (0)