DEV Community

Nazmi
Nazmi

Posted on

Using State Machine to build your React app

I hope this article finds you healthy and safe.

With the coronavirus causing chaos all over the world, I thought it would be useful to build an app that shows the latest metrics of each individual country cases. I will be using Next JS, styled-components and state machines!

Why state machine?

When I'm building an app in React, I will have a problem trying to understand how that component would work or react when a condition happens. State machine helps me structure my app into states, transitions and events so that my app becomes more predictive and eliminates any unexpected bugs or states.

In short, xState makes our code cleaner and maintainable in the long run! Believe me.

Read this article by the author of xState himself to understand more on state machines.

You can check out the finished repo at https://github.com/nazmifeeroz/covid-xstate-next and view the finished app deployed here, https://covid-next.now.sh/

Let's begin coding!

Setting up your app

I will be using Next.js to bootstrap the app. In your terminal, run:

$ mkdir covid-xstate-next && cd covid-xstate-next && npm init -y
Enter fullscreen mode Exit fullscreen mode

That should initialise npm in your folder, which you can then install the required packages:

$ yarn add react react-dom next xstate @xstate/react styled-components
Enter fullscreen mode Exit fullscreen mode

Once installed, create a new folder pages and a file called index.js:

$ mkdir pages && touch pages/index.js
Enter fullscreen mode Exit fullscreen mode

Open up package.json in your code editor and replace the test script to this:

  "scripts": {
    "dev": "next"
  }
Enter fullscreen mode Exit fullscreen mode

This will be the command to run your app. Before we can run, let's add some template in index.js:

import React from 'react'

const IndexPage = () => (
  <div>CoronaVirus Information</div>
)

export default IndexPage
Enter fullscreen mode Exit fullscreen mode

Now you can run yarn dev, and you should be able to open up your app in your browser at http://localhost:3000 and you should see your browser showing the texts we added from index.js.

The State Machine (The Brain)

Now that we are all set, let's dive into building the brain in our app!

We'll start by setting up the state chart of our app. In your index.js file, add this before your IndexPage function:

// pages/index.js
import { Machine } from 'xstate'

const statsMachine = Machine({
  id: 'statsMachine',
  initial: 'fetchStats',
  states: {
    fetchStats: {}
  }
})
Enter fullscreen mode Exit fullscreen mode

Here we initialise the machine by defining the initial state of the app which will be fetchStat. In layman terms, when the page is loaded, we want the app to fetch stats first! Pretty straight forward right?

In xState, we can run an asynchronous function that returns a promise. Whether it is resolved or rejected, we can define the transition it to the next state accordingly.

We will be using an open sourced api to retrieve the stats. Within the fetchStats state, we will call the invoke attribute which will fetch the data from the api:

// pages/index.js
import { Machine } from "xstate"

const statsApi = "https://coronavirus-19-api.herokuapp.com/countries"
const statsMachine = Machine({
  id: "statsMachine",
  initial: "fetchStats",
  states: {
    fetchStats: {
      invoke: {
        src: () =>
          new Promise(async (resolve, reject) => {
            try {
              const stats = await fetch(statsApi).then((response) =>
                response.json()
              )
              return resolve(stats)
            } catch (error) {
              console.log("error in fetching stats: ", error)
              return reject(error)
            }
          }),
      },
    },
  },
})
Enter fullscreen mode Exit fullscreen mode

The invoke attribute takes in a src which will be the function that will run a promise function. To get the resolved data or rejected error, we can get it from the onDone and onError attribute respectively:

// pages/index.js
import { assign, Machine } from 'xstate'

const statsApi = 'https://coronavirus-19-api.herokuapp.com/countries'

const statsMachine = Machine({
  id: 'statsMachine',
  initial: 'fetchStats',
  states: {
    fetchStats: {
      invoke: {
        src: () => new Promise((resolve, reject) => {
          try {
            const stats = 
              await fetch(statsApi)
                .then(response => response.json())

            return resolve(stats)
          } catch (error) {
            console.log('error in fetching stats: ', error)
            return reject(error)
          }
        }),
        onDone: { target: 'ready', actions: 'assignStats' },
        onError: 'error',
      }
    },
    ready: {},
    error: {}
  }
})
Enter fullscreen mode Exit fullscreen mode

As you might have guessed, when the promise fetches successfully, it resolves with the data and transits via the onDone attribute. The target is ready which is a state and waits there for the next event. If the promise returns error, it gets rejected and transits to the error state via the onError attribute.

Now if you notice, we have another attribute within the onDone which is the actions attribute. What that does is when the Promise resolves successfully, we want to assign the data into the context of the machine.

// pages/index.js
import { assign, Machine } from 'xstate'

const statsApi = 'https://coronavirus-19-api.herokuapp.com/countries'

const statsMachine = Machine({
  id: 'statsMachine',
  initial: 'fetchStats',
  context: {
    stats: null
  },
  states: {
    fetchStats: {
      invoke: {
        src: () => new Promise((resolve, reject) => {
          try {
            const stats = 
              await fetch(statsApi)
                .then(response => response.json())

            return resolve(stats)
          } catch (error) {
            console.log('error in fetching stats: ', error)
            return reject(error)
          }
        }),
        onDone: { target: 'ready', actions: 'assignStats' },
        onError: 'error',
      }
    },
    ready: {},
    error: {}
  }
},
{
  actions: {
    assignStats: assign((_context, event) => ({
      stats: event.data
    }))
  }
})
Enter fullscreen mode Exit fullscreen mode

In xState, we can define out actions into another object so that our machine object won't be so cluttered. In the assignStats action, we use the assign function that takes in the latest context and event that was passed from the resolved promise data and we store it in the stats prop.

Now we're done with the brain of our app! Let's move to the render function (the body).

The Body (Main Render function)

Now back to our JSX function, we want to show loading when the app is in fetchStats state. Then show the stats whens it's done at ready state.

// pages/index.js
import { assign, Machine } from "xstate"
import { useMachine } from "@xstate/react"

const statsApi = "https://coronavirus-19-api.herokuapp.com/countries"

const statsMachine = Machine({
  // … our machine object
})

const IndexPage = () => {
  const [current, send] = useMachine(statsMachine)

  return (
    <>
       <div>CoronaVirus Information</div> 
      {current.matches("fetchStats") && <div>Loading Stats</div>} 
      {current.matches("error") && <div>Error fetching stats</div>} 
      {current.matches("ready") && <div>Stats loaded!</div>} 
    </>
  )
}

export default IndexPage
Enter fullscreen mode Exit fullscreen mode

We used the useMachine hook to translate the statsMachine that return an array. The first element current will store all our machine details, on which state we are in and the context available we can use. When the current state is fetchStats, we show a loading component. When the current state is ready, we show the stats! You can imagine the possibilities when we have more states which we can then simply call the current.matches function.

This makes our code much cleaner and more understandable, making our app more maintainable. No more cluttered boolean states like isLoading, isFetching or hasError!

Now, lets create components for each individual states. We can put our components into its own folder under src. In our root project folder, run:

$ mkdir -p src/components && touch src/components/CountrySelector.js && touch src/components/stat.js && touch src/components/CountrySearch.js
Enter fullscreen mode Exit fullscreen mode

The CountrySelector component will show all the countries available in a dropdown box:

// src/components/CountrySelector.js
import React from "react"
import styled from "styled-components"

const CountrySelector = ({ handleChange, stats }) => (
  <div>
    <Selector onChange={handleChange}>
      <option>Select a country</option>
      {stats.map((stat, i) => (
        <option key={`${stat.country}-${i}`}>{stat.country}</option>
      ))}
    </Selector>
  </div>
)

const Selector = styled.select`
  -webkit-box-align: center;
  align-items: center;
  background-color: rgb(255, 255, 255);
  cursor: default;
  display: flex;
  flex-wrap: wrap;
  -webkit-box-pack: justify;
  justify-content: space-between;
  min-height: 38px;
  position: relative;
  box-sizing: border-box;
  border-color: rgb(204, 204, 204);
  border-radius: 4px;
  border-style: solid;
  border-width: 1px;
  transition: all 100ms ease 0s;
  outline: 0px !important;
  font-size: 15px;
  margin-bottom: 10px;
`

export default CountrySelector
Enter fullscreen mode Exit fullscreen mode

The CountrySelector component will receive the stats data to show in a dropdown box and the handleChange function which will pass the selected country back to our machine to show the stat of the country.

Next the CountrySearch component will allow user to search for a specific country. It receives the prop handleChange to update the machine for the country user has input.

// src/components/CountrySearch.js
import React from 'react'

const CountrySearch = ({ handleChange }) => {
  return (
    <input
      onChange={handleChange}
      placeholder="Search for a country"
      type="search"
    />
  )
}

export default CountrySearch
Enter fullscreen mode Exit fullscreen mode

Now for our last component stat will format and display the country stat:

// src/components/stat.js
import React from 'react'

const Stat = ({ stats }) => {
  return stats.map((stat, i) => (
    <div key={`${stat.country}-${i}`}>
      <br />
      <b>{stat.country}</b>
      <br />
      Cases: {stat.cases} | Today: {stat.todayCases} | Active: {stat.active}{' '}
      <br />
      Deaths: {stat.deaths} | Recovered: {stat.recovered} | Critical:{' '}
      {stat.critical}
    </div>
  ))
}

export default Stat
Enter fullscreen mode Exit fullscreen mode

We can now update our pages/index.js page to have all the components and pass its props.

// pages/index.js
import React from "react"
import { assign, Machine } from "xstate"
import { useMachine } from "@xstate/react"
import CountrySelector from "../src/components/CountrySelector"
import Stat from "../src/components/stat"
import CountrySearch from "../src/components/CountrySearch"

const statsApi = "https://coronavirus-19-api.herokuapp.com/countries"
const statsMachine = Machine({
  // … our machine object
})

const IndexPage = () => {
  const [current, send] = useMachine(statsMachine)
  return (
    <>
       <h3>CoronaVirus Information</h3> 
      {current.matches("fetchStats") && <div>Loading Stats</div>} 
      {current.matches("error") && <div>Error fetching stats</div>} 
      {current.matches("ready") && (
        <>
           
          <CountrySelector
            stats={current.context.stats}
            handleChange={(country) => send("COUNTRY_SELECTED", { country })}
          />
           
          <CountrySearch
            handleChange={(country) => send("COUNTRY_SELECTED", { country })}
          />
           
        </>
      )}
       
      {current.context.countriesSelected.length > 0 && (
        <Stat stats={current.context.countriesSelected} />
      )}
       
    </>
  )
}
export default IndexPage
Enter fullscreen mode Exit fullscreen mode

We have not added the event for COUNTRY_SELECTED and the context for countriesSelected in our machine. Lets do that now:

const statsMachine = Machine(
  {
    id: "statsMachine",
    initial: "fetchStats",
    context: {
      countriesSelected: [],
      stats: null,
    },
    states: {
      fetchStats: {
        invoke: {
          src: () =>
            new Promise(async (resolve, reject) => {
              try {
                const stats = await fetch(statsApi).then((response) =>
                  response.json()
                )
                return resolve(stats)
              } catch (error) {
                console.log("error in fetching stats: ", error)
                return reject(error)
              }
            }),
          onDone: { target: "ready", actions: "assignStats" },
          onError: "error",
        },
      },
      ready: {
        on: {
          COUNTRY_SELECTED: { actions: "updateSelectedCountry" },
        },
      },
      error: {},
    },
  },
  {
    actions: {
      assignStats: assign((_context, event) => ({
        stats: event.data,
      })),
      updateSelectedCountry: assign((context, event) => ({
        countriesSelected: context.stats.reduce(
          (acc, stat) =>
            stat.country
              .toLowerCase()
              .match(event.country.target.value.toLowerCase())
              ? [...acc, stat]
              : acc,
          []
        ),
      })),
    },
  }
)
Enter fullscreen mode Exit fullscreen mode

What we've just added here is whenever the CountrySelector or CountrySearch sends a new input by the user, it calls the COUNTRY_SELECTED event. This event calls upon the updateSelectedCountry action which will update the countries stats to display by the Stat component!

One of the many benefits I love about state machine is that your component gets decoupled from its logic and the UI. It also helps us have a clearer picture when we code, on what had happened, is happening and going to happen when user does this or that.

I hope this article helps to paint a good picture on why xState will make you code cleaner and maintainable on the long run!

Cheers! Happy coding!

Discussion (0)