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
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
Once installed, create a new folder pages
and a file called index.js
:
$ mkdir pages && touch pages/index.js
Open up package.json
in your code editor and replace the test
script to this:
"scripts": {
"dev": "next"
}
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
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: {}
}
})
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)
}
}),
},
},
},
})
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: {}
}
})
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
}))
}
})
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
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
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
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
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
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
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,
[]
),
})),
},
}
)
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!
Top comments (0)