React offers a simple and efficient way to structure our application through function components. In this tutorial we will see how to make these components ‘react’ to the user actions by using the State Hook.
React Hooks are a fairly recent addition to the React framework that came out with React 16.8. They come with the React library by default so if you start a new React project, you won’t have anything special to install.
There is a bunch of different hooks to use for various goals, but we will focus on the most basic one: the State Hook. Here’s a peek at what we will discuss here:
- Make individual components stateful instead of stateless, so our app can respond to click events.
- The difference between local state and global state, and how to implement it with the same State Hook.
- How to debug your app from state reference issues that are common in javascript applications.
The game will be operational at the end of the tutorial, but far from being polished. So I wrote some hints about how you can improve it if you wish to do so.
But first thing first, we need to lay the game’s foundations.
Building a static version
Let’s start by building a static version of our user interface. This means that all the elements will be present, but there won’t be any user interaction at this point.
To have a basic React application set up, we will use the create-react-app
tool that we have used during my tutorial about function components:
npx create-reac-app react-hooks-memory-game
We will also use Kenney’s Animal Pack for some free graphical assets.
I will go over the process quickly and show you the resulting code:
import Card from './Card'
import elephantImg from './assets/elephant.png'
import giraffeImg from './assets/giraffe.png'
import hippoImg from './assets/hippo.png'
import monkeyImg from './assets/monkey.png'
import pandaImg from './assets/panda.png'
import parrotImg from './assets/parrot.png'
import penguinImg from './assets/penguin.png'
import pigImg from './assets/pig.png'
import rabbitImg from './assets/rabbit.png'
import snakeImg from './assets/snake.png'
const images = [
{ alt: 'Elephant' , src: elephantImg },
{ alt: 'Giraffe', src: giraffeImg },
{ alt: 'Hippopotamus', src: hippoImg },
{ alt: 'Monkey', src: monkeyImg },
{ alt: 'Panda', src: pandaImg },
{ alt: 'Parrot', src: parrotImg },
{ alt: 'Penguin', src: penguinImg },
{ alt: 'Pig', src: pigImg },
{ alt: 'Rabbit', src: rabbitImg },
{ alt: 'Snake', src: snakeImg}
]
function App() {
const pairedImages = images.concat(images)
const shuffledImages = pairedImages.sort(() => Math.random() - 0.5)
return (
<div style={{ display: 'flex', flexWrap: 'wrap' }}>
{shuffledImages.map((image, index) => {
return <Card key={index} image={image} />
})}
</div>
);
}
export default App;
Here we start by importing our images and assign each of them an alternative text for accessibility.
Then we make a pair of each images by concatenating the images
array with itself, which results in a new array.
And we randomize the order of the images by calling the sort
1 method. For each image, this function will decide to put it before or after the next image, given the value returned by our callback is above or below 0. Our callback calls to Math.random()
which will generate a floating decimal number between 0 and 1. By subtracting 0.5 to it, we will have 50% chances for each image to move it before the next image, and 50% chance to move it after.
Finally, we display each resulting image in a <Card>
component. These cards are displayed in a CSS Flexible Box Layout 2 so we can adapt to the width of the screen and display an appropriate number of rows and cards per row.
The <Card>
component itself uses the image passed as a property to display it with some additional styling. We use FlexBox again, but this time, in order to center our images inside each card:
function Card({ image }) {
return (
<figure
style={{
boxSizing: 'border-box',
width: '160px',
height: '160px',
borderRadius: '16px',
border: '12px solid brown',
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
backgroundColor: 'beige'
}}
>
<img
src={image.src}
alt={image.alt}
style={{
maxWidth: '128px',
maxHeight: '128px'
}}
/>
</figure>
)
}
export default Card
You can try refreshing the page a few time, you should see the images changing places. You can also try to resize your browser windows, and you should see more or less card per row to fit the window size.
But at the moment, this is more of a static web page than an app. Let’s introduce some state into it!
Local state
React’s function components are stateless. This means that they will render the same way each time we provide them the same properties.
But in our memory game, we want the user to be able to flip our cards by clicking on them. This means that each card could be rendered either face up or face down.
To implement this feature, we will use React’s State Hook. Here’s how to write it:
import { useState } from 'react'
import reactLogo from './assets/logo.svg'
function Card({ image }) {
const [ isFlipped, setFlipped ] = useState(false)
const displayImg = isFlipped ? image : { src: reactLogo, alt: 'A card to flip' }
return (
<figure
onClick={() => setFlipped(true)}
style={{
// ...
}}
>
<img
src={displayImg.src}
alt={displayImg.alt}
style={{
maxWidth: '128px',
maxHeight: '128px'
}}
/>
</figure>
)
}
We start by importing the useState
function from the React library and the SVG 3 logo that exists in each new create-react-app
project.
Inside our card component, we will call the useState
function with an initial value of false
. This function will return us an array containing both our state value and a setter function for this state. So we use array destructuring 4 to store these in two separate constants.
Then regarding the actual state, we set the image source and alternative text to be displayed inside our card.
Finally, we call our setter function from the onClick
event of our <figure>
element. When the user click on this element, the setter function will mutate the state’s value to be true
.
This state is local, which means that each instance of our <Card>
component maintain its own isFlipped
value. So if you click on one card, it is only this card that will be flipped.
But this is not really the behavior we want for our memory game. Let’s define the game rules:
When an user clicks on a card:
- If it is the first card that she picked, the card is flipped face up.
- If it is the second card that she picked, the two cards are compared:
- If the two cards images match, both cards stay face up.
- If the two cards images don’t match, both cards are flipped back.
- Then the picked cards are reset.
Since we need to compare two cards, we cannot use our state locally in each card, we need to make it global.
Global state
To make the whole state of our game accessible within each card, we need to locate it in the card components common ancestor: the <App>
component.
function App() {
const cardsData = images.map((image, index) => ({image, isFlipped: false}))
const pairedCards = cardsData.concat(cardsData)
const shuffledCards = pairedCards.sort(() => Math.random() - 0.5)
const [ cardsState, setCardsState ] = useState(shuffledCards)
function flipCard(index) {
cardsState[index].isFlipped = true
setCardsState(cardsState)
}
return (
<div style={{ display: 'flex', flexWrap: 'wrap' }}>
{cardsState.map((cardData, index) => {
return (
<Card
key={index}
image={cardData.image}
isFlipped={cardData.isFlipped}
flipCard={() => flipCard(index)}
/>
)
})}
</div>
);
}
So we modify our data array to represents our cards: each card has now an isFlipped
property that is set to false
by default. This new array is paired and shuffled like before. but it is now passed as our initial state.
Then we create our event function, that will set a card isFlipped
property to true and set the new global state.
We change our loop to pass the image
and isFlipped
property to each card. And we also pass the function that will call our flipCard
function with an index
parameter, that represents the card’s position in the array.
In our <Card>
component, will use the new properties and remove the local state hook:
function Card({ image, isFlipped, flipCard }) {
const displayImg = isFlipped ? image : { src: reactLogo, alt: 'A card to flip' }
return (
<figure
onClick={() => flipCard()}
// ...
But now clicking on our cards does not flip them anymore! Let’s see what happens.
Pass state by copy instead of by reference
React is quite smart, by default it tries to optimize the repaint of the screen by only re-rendering the components whose state has changed.
So, when we had our local state inside each <Card>
, React did only re-render the one on which we clicked on, because other components states didn’t change.
But now that we are changing our global state, shouldn’t the whole <App>
be re-rendered on each click? In a way, it should. But what if I told you that we didn’t actually changed the global state?
The faulty lines are inside our flipCard
function. Indeed, we change the isFlipped
values of one the cards, but we then pass again the same array to the state. One of its members have changed, but it is still the same array!
So when the State Hook receives the cardState
variable, it just see that this it points to the same array than before, and then choose not to re-render the <App>
.
We could fix this by creating a copy of our array, that contains every card in the previous array. This is easy to write using array destructuring again:
function flipCard(index) {
cardsState[index].isFlipped = true
setCardsState([...cardsState])
}
But there’s still an issue: when we click on a card, the other card of its pair is flipped as well!
This is because the entries in our arrays are also references to the objects that contains our actual data.
This means that, when we concatenated the cardsData
array to itself to make pairs of cards, we did not create new cards objects, but assigned twice the references to the same object inside the resulting array.
So modifying so by accesing one of these two refernces in the array, we will in fact modify the same object, which is then used as a property of two <Card>
components…
We need to instantiate new objects instead. We will copy all the properties of the cards, and set them in our new state. We will use the destructuring syntax again, that works on objects as well.
function flipCard(index) {
const nextCardsState = cardsState.map(card => ({...card}))
nextCardsState[index].isFlipped = true
setCardsState(nextCardsState)
}
Exercise
Now that you know everything about the State Hook, you may use it to implement the game rules by yourself. Remember the game rules we have defined earlier.
You can find the source code on GitHub. And my solutions as well.
Hints:
- Use another state to remember the card that the user selected previously.
- What should happen if the user click again on a card he already picked?
- You will need to use a javascript interval5 to show the two cards the user has selected for a little time before flipping them back.
- You could lock down the user’s action while you are showing these two selected cards by using a third state hook.
Learning material
To learn the intricacies of React Hooks, I read and exercised with Learn React Books by Daniel Bugl6.This book start by making you implement your very own State Hook to understand what is happening inside React.
You will then learn more specific yet still very common hooks to use. You will apply them to a blogging application as your learn, so you get a better understanding of to get the best from React Hooks.
If you like the example of a memory game, here is how you can improve this basic start into a fully fledged game:
- Move the handling of global state inside a Reducer Hook, and add a possibility to restart the game without having to refresh your browser.
- Use the Effect Hook to preload images before starting the game. You can draw inspiration from this Medium post by Jack: How to Preload Images into Cache in React JS
- Use the effect Hook to add a timer into the game.
- Refactor your code into sub-components by using the Context Hook. Then add a score display.
- Create your own Custom Hooks to reuse some of the behavior inside another game.
- Mozilla Developers Network, JavaScript Reference, Array.prototype.sort(), last modified: February 1 2021, https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/sort↩︎
- Mozilla Developers Network, Web Docs Glossary: Definitions of Web-related terms, Flexbox, last modified: December 30 2020, https://developer.mozilla.org/en-us/docs/Glossary/Flexbox↩︎
- Mozilla Developers Network, Web technology for web developers, SVG: Scalable Vector Graphics, last modified February 3, 2021, https://developer.mozilla.org/en-US/docs/Web/SVG↩︎
- Mozilla Developers Network, Javascript Reference, Destructuring Assignment, https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Operators/Destructuring_assignment↩︎
- Mozilla Developers Network, Web APIs, WindowOrWorkerGlobalScope.setInterval(), https://developer.mozilla.org/en-US/docs/Web/API/WindowOrWorkerGlobalScope/setInterval↩︎
- Daniel Bugl. Learn React Hooks. Birmingham, UK: Packt Publishing, 2019.↩︎
Leave a Reply
You must be logged in to post a comment.