DEV Community

loading...
Cover image for 1/7 GUI Tasks with React and XState: Counter

1/7 GUI Tasks with React and XState: Counter

Josh Branchaud
I'm a developer and consultant focused primarily on the web, specializing in React, Ruby on Rails, and PostgreSQL. Newsletter: https://crafty-builder-6996.ck.page/e169c61186
・6 min read

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.

A Few Learnings

The Core of a State Machine

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,
          }),
        },
      },
    },
  },
};
Enter fullscreen mode Exit fullscreen mode
  1. The initial state that the machine will be in when it is first turned on.
  2. 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.
  3. A finite set of states, at least one, that the machine can be in. In this case I just have the counting state.
  4. 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 the INCREMENT event. When this event is triggered in the counting state, it will transition to itself and an assign action will update the count in the context.

Self Transitions

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.

Internal Transitions

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,
          }),
        },
      },
    },
  },
Enter fullscreen mode Exit fullscreen mode

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,
          }),
        },
      },
    },
  },
Enter fullscreen mode Exit fullscreen mode

External Transitions

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'");
      },
    },
  },
Enter fullscreen mode Exit fullscreen mode

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.


Full Implementation Walk Through

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
Enter fullscreen mode Exit fullscreen mode

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";
Enter fullscreen mode Exit fullscreen mode

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';
Enter fullscreen mode Exit fullscreen mode

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);
Enter fullscreen mode Exit fullscreen mode

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 is counting. 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 is count. We will have it start at 0.
  • 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',
        },
      },
    },
  },
Enter fullscreen mode Exit fullscreen mode

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);
Enter fullscreen mode Exit fullscreen mode

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>
    </>
  );
Enter fullscreen mode Exit fullscreen mode

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}.

Discussion (0)