17
1/7 GUI Tasks with React and XState: Counter
The first part of this article explores a couple learnings from implementing a Counter with XState and React. A Counter is the first of the 7 GUIs tasks. The second, lengthier part of this article will walk through a full explanation of my solution.
You'll get a lot out of the first part even if you don't want to read through the full walk through.
The state machine that backs this Counter is one of the most basic XState machines you can build. I find that instructive because it shows me, once I cut away all the other features, what is at the core of defining a functioning state machine.
const countingMachineDefinition = {
initial: "counting",
context: { count: 0 },
states: {
counting: {
on: {
INCREMENT: {
actions: assign({
count: (context) => context.count + 1,
}),
},
},
},
},
};
- The
initial
state that the machine will be in when it is first turned on. - The starting
context
that the machine will start with in its initial state. This is the secondary state, all the data beyond the current state itself. - A finite set of
states
, at least one, that the machine can be in. In this case I just have thecounting
state. - Each state can have a set of one or more events
on
which it will respond to with a transition and actions. In this case I just have theINCREMENT
event. When this event is triggered in thecounting
state, it will transition to itself and anassign
action will update thecount
in thecontext
.
A state's event that doesn't specify a target
will implicitly do a self transition. In the state diagram, rather than the an arrow going from this state to another state, the arrow points to itself. This means that when that state receives that event, it will transition right back to itself. A transition always takes place.
Because the target
wasn't specified at all for counting
's INCREMENT
event, the self transition will be an internal transition (as opposed to an external transition). This means that on this internal transition, we don't leave the current state node. The implications of that are that the entry
and exit
actions of that state will not be triggered.
Another, more explicit way to define an internal transition would be to specify the internal
option as true
.
states: {
counting: {
on: {
INCREMENT: {
internal: true,
actions: assign({
count: (context) => context.count + 1,
}),
},
},
},
},
Another explicit way of doing the same thing here is to say outright that the target
is undefined
.
states: {
counting: {
on: {
INCREMENT: {
target: undefined,
actions: assign({
count: (context) => context.count + 1,
}),
},
},
},
},
Out of curiosity, let's look at a self transition that involves an external transition.
states: {
counting: {
on: {
INCREMENT: {
target: "counting",
actions: assign({
count: (context) => context.count + 1,
}),
},
},
entry: () => {
console.log("Entering 'counting'");
},
exit: () => {
console.log("Exiting 'counting'");
},
},
},
We include the target
option which points to the parent state, counting
. To be sure that this brings back the entry
and exit
actions, I've add a couple logging actions. On each button click, we'll see the exit
and then immediately the entry
actions be triggered.
That's it... for my learnings from this super small state machine. If you are interested in digging into the full implementation keep reading.
Otherwise, thanks for reading. If you enjoy my writing, consider joining my newsletter or following me on twitter.
The first of the 7 GUIs tasks is to create a counter. This is a classic "Hello, World"-esque challenge for both UI frameworks and state management libraries. In our case, we are using React (a UI framework) and XState (a state management library). So we'll be exercising both aspects of this.
The task description is:
The task is to build a frame containing a label or read-only textfield T and a button B. Initially, the value in T is “0” and each click of B increases the value in T by one.
Counter serves as a gentle introduction to the basics of the language, paradigm and toolkit for one of the simplest GUI applications imaginable. Thus, Counter reveals the required scaffolding and how the very basic features work together to build a GUI application. A good solution will have almost no scaffolding.
The author of 7 GUIs describes the goal of this first task as: "understanding the basic ideas of a language/toolkit."
In that spirit, the very first thing we'll have to understand is the interplay between React and XState.
Let's start by installing both XState and its React bindings into our React application.
$ yarn add xstate @xstate/react
The part that is core to XState is being able to turn a JSON description of a machine into a machine. This is done with the createMachine
function which we will import.
import { createMachine } from "xstate";
The React bindings part is when we interpret this machine definition into something that React can interact with the useMachine
hook.
import { useMachine } from '@xstate/react';
Let's define a counting machine in a separate machine.js
file.
import { createMachine } from "xstate";
const countingMachineDefinition = {
initial: "counting",
context: { count: 0 },
states: {
counting: {
on: {
INCREMENT: {
actions: 'incrementCount',
},
},
},
},
};
export const countingMachine = createMachine(countingMachineDefinition);
This machine isn't quite ready, but it introduces most of the pieces we need to get our count on.
Our machine definition is made up, in this case, of initial
, context
, and states
.
-
initial
specifies the state that this machine should start in when it is first interpreted. Our starting state iscounting
. That's also our only state. -
context
is where we define an object containing any initial context for our machine. The only piece of context we are keeping track of iscount
. We will have it start at0
. -
states
lists the finite set of states that make up this state machine. At any given time, our machine is going to be in one of these defined states. This is an extremely simple state machine that has a single state—counting
.
Let's look a little closer at the states
definition.
states: {
counting: {
on: {
INCREMENT: {
actions: 'incrementCount',
},
},
},
},
The counting
state contains some information about itself. It tells us what events it responds to in the on
object. Since we are only counting up, the counting
state will only respond to the INCREMENT
event.
Often the response to an event will be one or more actions as well as a transition to some other target state. This machine, only having one state, doesn't transition to another state. It implicitly does an internal self transition. It is like it is pointing to itself, but without making a show of it.
When the INCREMENT
event is sent, the incrementCount
action will be triggered. You might have noticed that there is no function definition for incrementCount
.
In fact, if we were to start up this machine and send it the INCREMENT
event, we would see the following warning in the console.
Warning: No implementation found for action type 'incrementCount'
We still have to implement that.
We can either replace the 'incrementCount'
string with an inline function or we can define a function under that name in an actions
section.
The function is small enough that I'll just replace the string.
import { createMachine, assign } from "xstate";
const countingMachineDefinition = {
initial: "counting",
context: { count: 0 },
states: {
counting: {
on: {
INCREMENT: {
actions: assign({
count: (context) => context.count + 1,
}),
},
},
},
},
};
export const countingMachine = createMachine(countingMachineDefinition);
Notice I imported assign
from xstate
. It is being used to generate an action handler that will update the machine's context. The only context that needs updating is count
. Similar to React, Redux, and other state management libraries, the context value is updated using a function that provides the current context and returns the updated context value.
So, each time the machine receives the INCREMENT
event, it will trigger this assign({ ... })
action that increments the count. Each subsequent event, will be working with the newest version of the context
which will contain the incremented count.
And that's it, that's the counter machine.
Here is how we can use it (in a React component).
import React from "react";
import { useMachine } from "@xstate/react";
import { countingMachine } from "../../src/machines/counter";
const Task1 = () => {
const [state, send] = useMachine(countingMachine);
return (
<>
<p>Count: {state.context.count}</p>
<button onClick={() => send('INCREMENT')}>
Increment
</button>
</>
);
Each time the button is clicked, the INCREMENT
event will be sent to the machine. The count
context will be incremented and that value will trickle down to being rendered into the view via {state.context.count}
.
17