Practical React & Redux · Part 4
This is the final part of the React workshops series created by our team. Let's learn how to deal with asynchronous operations.
This is the final part of the React workshops series created by our team. Today we are going to finish our simple React application adding an asynchronous operation and converting the app into a simple game.
If you want to follow along, you can download the code here and can check out the part-3
tag and update the code as you read.
Introduction
Our application looks great, and we have our state in a single place thanks to Redux. The problem is in a real application you usually need to deal with asynchronous operations like external APIs for example.
Today we are going to enhance the app and convert it to a simple game where you will be able to hug the pets in turns.
Using an API an dealing with asynchronous code
Our pets need a lot of attention, and we cannot hug them all! Let's make them wait turns and hug one of them at a time. I don't want to have the responsibility to decide which pet can receive hugs at a time, so we are going to use an API to generate random numbers instead (a bit overengineered but... I really cannot decide between them 😭).
First of all, let's enhance our application state and add a huggable
property for our pets:
// src/pets/store/index.js
import { HUG_PET } from './actions'
export const DOG = 'dog'
export const CAT = 'cat'
export const PETS_INITIAL_STATE = [
{ kind: DOG, name: 'Boira', initialScore: 13, hugs: 0, huggable: false },
{ kind: DOG, name: 'Black', initialScore: 13, hugs: 0, huggable: false },
{ kind: CAT, name: 'Uhura', initialScore: 13, hugs: 0, huggable: false },
]
const pets = (state = PETS_INITIAL_STATE, action) => {
switch (action.type) {
case HUG_PET: {
const { name } = action.payload
return state.map((pet) => {
if (pet.name === name) {
return { ...pet, hugs: pet.hugs + 1 }
}
return pet
})
}
default: {
return state
}
}
}
export default pets
Then use that property to enable/disable the "Give hug" buttons:
// src/pets/containers/pets/Pets.js
import React from 'react'
import usePetsStore from '../../hooks/usePetsStore'
import { DOG } from '../../store'
import Dog from '../../../dogs/components/dog/Dog'
import Cat from '../../../cats/components/cat/Cat'
const Pets = () => {
const petsStore = usePetsStore()
const pets = petsStore.getAllPets()
const totalHugs = petsStore.getTotalHugs()
return (
<>
<p>Total hugs: {totalHugs} </p>
{pets.map(({ kind, name, initialScore, huggable }) => (
<div key={name}>
{kind === DOG ? (
<Dog key={name} name={name} initialScore={initialScore} />
) : (
<Cat key={name} name={name} initialScore={initialScore} />
)}
<button onClick={() => petsStore.hugPet(name)} disabled={!huggable}>
Give a hug to {name}!
</button>
</div>
))}
</>
)
}
export default Pets
As you can see it was straightforward! We just used the disabled
prop and pass them the previous boolean value so all buttons should be disabled by now.
Now it's the time to change our application state and you already know we cannot do that without creating an action. Let's create a new action using a new action creator called chooseNextHuggablePet
:
// src/pets/store/actions.js
export const HUG_PET = 'HUG_PET'
export const CHOOSE_HUGGABLE_PET = 'CHOOSE_HUGGABLE_PET'
export const hugPet = (name) => {
return {
type: HUG_PET,
payload: {
name,
},
}
}
export const getRandomPet = (pets) => {
return new Promise((resolve) => {
setTimeout(() => {
const petChosen = pets.find((pet) => pet.name === 'Boira')
resolve(petChosen)
}, 1000)
})
}
export const chooseNextHuggablePet = async (pets) => {
const petChosen = await getRandomPet(pets)
return {
type: CHOOSE_HUGGABLE_PET,
payload: {
petChosen,
},
}
}
I know... it's not that fair! 😂 I promise you this is just a boilerplate code that we will replace with a fair solution in a few minutes. I kind of emulated a delayed response using a Promise
with a setTimeout
function. After one second we are returning a pet named "Boira" 🐶. Let's use this new action in our pets container!
The idea is to choose a new pet every 15 seconds. We are going to learn how to use another React built-in hook: the useEffect
hook! We can use this hook to call a function when a component is created so it is a good place to set up our timer function:
// src/pets/containers/pets/Pets.js
import React, { useEffect } from 'react'
import usePetsStore from '../../hooks/usePetsStore'
import { DOG } from '../../store'
import Dog from '../../../dogs/components/dog/Dog'
import Cat from '../../../cats/components/cat/Cat'
const Pets = () => {
const petsStore = usePetsStore()
const pets = petsStore.getAllPets()
const totalHugs = petsStore.getTotalHugs()
useEffect(() => {
const intervalId = setInterval(() => {
petsStore.chooseNextHuggablePet()
}, 15_000)
petsStore.chooseNextHuggablePet()
return () => {
clearInterval(intervalId)
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
return (
<>
<p>Total hugs: {totalHugs} </p>
{pets.map(({ kind, name, initialScore, huggable }) => (
<div key={name}>
{kind === DOG ? (
<Dog key={name} name={name} initialScore={initialScore} />
) : (
<Cat key={name} name={name} initialScore={initialScore} />
)}
<button onClick={() => petsStore.hugPet(name)} disabled={!huggable}>
Give a hug to {name}!
</button>
</div>
))}
</>
)
}
export default Pets
The previous code is not gonna work yet because we haven't created the method inside our usePetsStore
custom hook
. Let's focus on the useEffect
hook first! As you can see the useEffect
receives two params: the first one is the function that will be executed when the component has been created, the second one is a list of variables that will trigger the previous function if some value change. In our case, I don't want to add any variable but eslint
is warning us to be exhaustive and add the petsStore
variable. Adding petsStore
will cause a serious bug in our application, so I am just disabling the eslint
recommendation for that line.
Another interesting thing is we are returning a function in that callback function 😅. That's pretty common because in a useEffect
we usually create subscriptions or timers that we need to clean when the component is destroyed. This is essential to avoid memory leaks! 🚰
After setting the interval I am just executing the method right away to choose our first pet. Let's continue with the implementation of our usePetsStore
custom hook:
// src/pets/hooks/usePetsStore.js
import { useSelector, useDispatch } from 'react-redux'
import * as fromActions from '../store/actions'
const usePetsStore = () => {
const pets = useSelector((state) => state.pets)
const dispatch = useDispatch()
return {
getAllPets() {
return pets
},
getTotalHugs() {
return pets.reduce((hugs, pet) => hugs + pet.hugs, 0)
},
hugPet(name) {
dispatch(fromActions.hugPet(name))
},
chooseNextHuggablePet() {
dispatch(fromActions.chooseNextHuggablePet(pets))
},
}
}
export default usePetsStore
Nothing fancy here! Just using dispatch
again with our previous action creator. I am also passing it the list of the pets that we collected using the useSelector
hook
. Let's hug our pets. Oh... you get an error, right? That's "normal" let me explain 😅.
We can only dispatch
things that are actions (remember, an object with a type
property) but our action creator is not returning an action, right? It is a bit subtle because I used the async/await
syntax sugar, but we are returning a Promise
object under the hood and Redux doesn't know how to handle Promise
objects.
We need to extend Redux with something that is called a middleware
. Maybe you know what a middleware is in another context and in fact the concept is very similar when it is applied to Redux. A middleware is just a function that transforms the things that we are dispatching and transforms them to another thing!. Lucky for us there is a middleware that understand how a Promise
works: redux-thunk
. We need to install the package first:
$ npm i redux-thunk
Then we need to add this middleware
in our application. Let's change our App.js
like this:
// src/App.js
import React from 'react'
import thunk from 'redux-thunk'
import { createStore, combineReducers, applyMiddleware } from 'redux'
import { Provider } from 'react-redux'
import pets from './pets/store'
import Pets from './pets/containers/pets/Pets'
const App = () => {
const store = createStore(combineReducers({ pets }), applyMiddleware(thunk))
return (
<Provider store={store}>
<Pets />
</Provider>
)
}
export default App
We added the applyMiddleware
call with the middleware that we just installed. Let's reload our application and hug all our pets! Nope, still doesn't work 😸. To make this work we need to dispatch a function instead of a Promise
. That means we need to change our action creator to return a function:
// src/pets/store/actions.js
export const HUG_PET = 'HUG_PET'
export const CHOOSE_HUGGABLE_PET = 'CHOOSE_HUGGABLE_PET'
export const hugPet = (name) => {
return {
type: HUG_PET,
payload: {
name,
},
}
}
export const getRandomPet = (pets) => {
return new Promise((resolve) => {
setTimeout(() => {
const petChosen = pets.find((pet) => pet.name === 'Boira')
resolve(petChosen)
}, 1000)
})
}
export const chooseNextHuggablePet = (pets) => async (dispatch) => {
const petChosen = await getRandomPet(pets)
dispatch({
type: CHOOSE_HUGGABLE_PET,
payload: {
petChosen,
},
})
}
Well maybe I got a bit carried away, sorry! The change might not be very subtle. First I changed the function to return another function! This is that weird syntax: () => () => {}
. Let me rewrite this:
export const chooseNextHuggablePet = (pets) => {
return async (dispatch) => {
// ...
}
}
Maybe now looks better, feel free to use whatever form you prefer 👍. The important takeaway here is the chooseNextHuggablePet
is returning a function now. In the inner function we have the dispatch
argument. This is the same function that we have been using with useDispatch
in our usePetsStore
custom hook.
Then if you check the function body we are not returning anything now. We are just using dispatch
after using our fair random service to get the chosen pet. Feel free to refactor the previous code to use another action creator instead of dispatching the object. I leave this as an exercise for you people!
Finally... still not working! Of course, we forgot to update the reducer function (to be honest I forgot about it while writing this workshop so that's on me). Let's enhance our reducer to update the huggable
state for the chosen pet:
// src/pets/store/index.js
import { HUG_PET, CHOOSE_HUGGABLE_PET } from './actions'
export const DOG = 'dog'
export const CAT = 'cat'
export const PETS_INITIAL_STATE = [
{ kind: DOG, name: 'Boira', initialScore: 13, hugs: 0, huggable: false },
{ kind: DOG, name: 'Black', initialScore: 13, hugs: 0, huggable: false },
{ kind: CAT, name: 'Uhura', initialScore: 13, hugs: 0, huggable: false },
]
const pets = (state = PETS_INITIAL_STATE, action) => {
switch (action.type) {
case HUG_PET: {
const { name } = action.payload
return state.map((pet) => {
if (pet.name === name) {
return { ...pet, hugs: pet.hugs + 1 }
}
return pet
})
}
case CHOOSE_HUGGABLE_PET: {
const { petChosen } = action.payload
return state.map((pet) => {
return { ...pet, huggable: pet.name === petChosen.name }
})
}
default: {
return state
}
}
}
export default pets
This is very similar to the previous update we made in our reducer. I am checking the action's type
and then updating all the pets from our collection. I set the huggable
to true
if it is the pet that we are looking for. Regarding of the implementation it is essential to clear all the necessary flags when doing things like that so don't forget to set to huggable: false
to the other pets!
The application is now working and you can hug all the pets again fairly way! 🎉
Okay, okay... I promised you to fix this so, let's do it! Back to our actions file:
// src/pets/store/actions.js
import { getRandomPet } from '../services/fair-service'
export const HUG_PET = 'HUG_PET'
export const CHOOSE_HUGGABLE_PET = 'CHOOSE_HUGGABLE_PET'
export const hugPet = (name) => {
return {
type: HUG_PET,
payload: {
name,
},
}
}
export const chooseNextHuggablePet = (pets) => async (dispatch) => {
const petChosen = await getRandomPet(pets)
dispatch({
type: CHOOSE_HUGGABLE_PET,
payload: {
petChosen,
},
})
}
I like to keep things separated as possible, so I decided to remove the getRandomPet
function from here (since it wasn't an action creator after all) and used a service
instead. It's a fancy word to just "encapsulate things" and in our case, since I don't have some state to manage, I decided to use a plain ES Module and not a class
.
Let's add the implementation:
// src/pets/services/fair-service.js
const BASE_URL = 'https://www.random.org/integers'
const buildRandomServiceUrl = (max) => {
return `${BASE_URL}/?num=1&min=0&max=${max}&col=1&base=10&format=plain&rnd=new`
}
const fallBackRandomPet = (pets) => {
return pets[Math.ceil(Math.random() * pets.length - 1)]
}
export const getRandomPet = async (pets) => {
try {
const response = await fetch(buildRandomServiceUrl(pets.length - 1))
if (response.ok) {
const random = await response.json()
return pets[random]
}
return fallBackRandomPet(pets)
} catch (e) {
console.error(`Something bad happened: ${e}`)
// Adding a fallback in case the previous request fails
return fallBackRandomPet(pets)
}
}
I promise you we have a really fair system now! Let's do a quick walkthrough of the code even it's just vanilla JS. I am using the fetch
function to do an AJAX request to https://www.random.org. Passing the correct path
and parameters
we can get an integer between 0
and pets.length - 1
(the range is inclusive so for a list of 3 elements I want 0
, 1
and 2
because the array index starts at 0
).
If the response
is ok
I extract the value using the json
method. If the response is not ok, or we raise an error we fallback to another fair method that uses the Math
standard library to generate a random number.
If you reload the application you can start hugging the pets as much as you want! Remember that you can only hug one pet at a time! 🐶😸🕹
Conclusion
You made it, congratulations! This is everything we wanted to teach you with our React and Redux workshop and I hope you enjoyed it! Stay tuned for more workshops created by Codegram.
Cover photo by Adam Patterson