Book Preview: Learning React by Building Games
A chapter from a React.js book I am currently writing and planning to publish by end-of-year.
--
Please note that this article is not a beginner tutorial. I will be assuming that you know the basics of React. If you are absolutely new to React, start by writing your first React component and learn the fundamental concepts of React.
I named the game we are going to build in this article The Target Sum. It is a simple one: you start with a random number in the header, the target (42 in the screenshot above), and a list of random challenge numbers below that target (the 6 numbers in the screenshot above).
Four of the six random numbers used above (8, 5, 13, 16) sum up to exactly the target number 42. Picking the correct subset of numbers is how you win the game.
Wanna play a few rounds? Click the Start button below
Where you able to win? I am SO bad at this game.
Now that you know what we are going to build, let’s dive right in. Don’t worry, we will build this game in small increments and one step at a time.
Step #1: Initial markup and styles
It is a good idea to start with any known markups and styles and get those out of the way. With simple games like this one, this is usually an easy task. Just put mock static content where the dynamic content will eventually be.
To keep this article as short as possible and focus on React, I will start with some initial ready markup and CSS. Here is a jsComplete code session that you can use to start out with:
If you want to follow along with a different development environment, here is all the CSS that I used to style the markup above:
.game {
display: inline-flex; flex-direction: column;
align-items: center; width: 100%;
}
.target {
border: thin solid #999; width: 40%; height: 75px;
font-size: 45px; text-align: center; display: inline-block;
background-color: #ccc;
}
.challenge-numbers {
width: 85%; margin: 1rem auto;
}
.number {
border: thin solid lightgray; background-color: #eee;
width: 40%; text-align: center; font-size: 36px;
border-radius: 5px; margin: 1rem 5%; display: inline-block;
}
.footer {
display: flex; width: 90%; justify-content: space-between;
}
.timer-value { color: darkgreen; font-size: 2rem; }
I am not very good with CSS and some of my choices above are probably questionable. Do not get distracted by that; we have a game to build.
Step #2: Extracting components
Once we reach a good state for the initial markup and styles, thinking about components is a natural next step. There are many reasons to extract part of the code into a component. For this example, I would like to focus on just one reason: Shared Behavior.
A good indicator of the need for a new component is when multiple elements are going to share the exact same behavior. In our example, all of the six random challenge numbers will be clickable to sum towards a target number and their clicks will trigger UI changes. This shared behavior means that we should create a component to represent a single number. I will simply name that Number
.
The new changes introduced in every code snippet below are highlighted in bold.
// Step #2: https://jscomplete.com/repl/?j=S1M0BsQ1Gclass Number extends React.Component {
render() {
return <div className="number">{this.props.value}</div>;
}
}class Game extends React.Component {
render() {
return (
<div className="game">
<div className="target">42</div>
<div className="challenge-numbers">
<Number value={8} />
<Number value={5} />
<Number value={12} />
<Number value={13} />
<Number value={5} />
<Number value={16} />
</div>
<div className="footer">
<div className="timer-value">10</div>
<button>Start</button>
</div>
</div>
);
}
}ReactDOM.render(<Game />, document.getElementById('mountNode'));
You might want to extract more components such as a Target
or a Timer
component. While adding components like these might enhance the readability of the code, I am going to keep the example simple and use only two components: Game
and Number
.
Step #3: Making things dynamic
Every time we render a new game, we need to create a new random target number. This is easy; we can use Math.random()
to get a random number within the min...max
range using this function:
// Top-level functionconst randomNumberBetween = (min, max) =>
Math.floor(Math.random() * (max - min + 1)) + min;
If we need a target number between 30
and 50
, we can simply use randomNumberBetween(30, 50)
.
Then, we need to generate the six random challenge numbers. I am going to exclude the number 1
from these numbers and probably not go above 9
for the first level, so we can simply use randomNumberBetween(2, 9)
in a loop to generate all challenge numbers. Easy, right? RIGHT?
This set of random challenge numbers need to have a subset that actually sums to the random target number that we generated. We cannot just pick any random number; we have to pick some factors of the target number (with some of their factorization results) and then some more distracting random numbers. This is hard!
If you were doing this challenge in a coding interview, what you do next might make or break the job offer. What you need to do is to simply ask yourself: is there an easier way?
Take a minute and think about just this problem. To make things interesting, let’s make the size of the challenge numbers list dynamic. The Game
component will receive two new properties:
<Game challengeSize={6} challengeRange={[2, 9]} />
The simple alternative to the factorization problem above is to pick the random challenge numbers first and then compute the target from a random subset of these challenge numbers.
This is easier. We can use Array.from
to create an array of random numbers with the help of the randomNumberBetween
function. We can then use the lodash sampleSize
method to pick a random subset and then just sum that subset and call it a target!
Since all of these numbers are not going to change during a single game session, we can safely define them as instance properties.
Here are the modifications that we need so far:
// In the Game classchallengeNumbers = Array
.from({ length: this.props.challengeSize })
.map(() => randomNumberBetween(...this.props.challengeRange));target = _.sampleSize(
this.challengeNumbers,
this.props.challengeSize - 2
).reduce((acc, curr) => acc + curr, 0);render() {
return (
<div className="game">
<div className="target">{this.target}</div>
<div className="challenge-numbers">
{this.challengeNumbers.map((value, index) =>
<Number key={index} value={value} />
)}
</div>
<div className="footer">
<div className="timer-value">10</div>
<button>Start</button>
</div>
</div>
)
}
Note how I used the index
value from the map
call as the key
for every Number
component. Remember that this is okay as long as we are not deleting, editing, or re-arranging the list of numbers, which we will not be doing here.
You can see the full code we have so far here.
Step #4: Deciding what goes on the state
When the Start button is clicked, the game will move into a different state and the 10
second timer will start its countdown. Since these are UI changes, a game status and the current value of that timer at any given time should be placed on the state.
When the game is in the playing
mode, the player can start clicking on challenge numbers and every click will trigger a UI change as well. When a number is selected, we need the UI to represent it differently. This means we also need to place the selected numbers on the state as well. We can simply use an array for those.
However, we cannot use the number values in this new array because the list of random challenge numbers might have repeated values. What we need to designate as selected are the unique IDs of these numbers. We used a number positional index as its ID so we can use that to uniquely select a number.
All of these identified state elements can be defined on the state of the Game
component. The Number
component does not need any state.
Here is what we need to place on the Game
component state so far:
// In the Game componentstate = {
gameStatus: 'new' // new, playing, won, lost
remainingSeconds: this.props.initialSeconds,
selectedIds: [],
};
Note how I made the initial value for the number of remainingSeconds
customizable as well and used a new game-level prop (initialSeconds
) for that:
<Game
challengeSize={6}
challengeRange={[2, 9]}
initialSeconds={10}
/>
To be honest, we do not need the gameStatus
to be on the state at all. It is mostly computable. However, placing it on the state is an exception that I am intentionally making as a simplified form of caching that computation. Ideally, caching this computation is better done as an instance property, but I will keep it on the state to keep things simple.
What about the background colors used for the target number when the player wins or loses a game? Do those need to go on the state?
Not really. Since we have a gameStatus
element, we can use that to lookup the right background color. The dictionary of background colors can be a simple static Game
property (or you can pass it down if you want to make it customizable):
// In the Game componentstatic bgColors = {
playing: '#ccc',
won: 'green',
lost: 'red',
};
You can see the full code we have so far here.
Step #5: Designing views as functions of data and state
This is really the core of React. Now that we identified all of the data and state this game needs, we can design the whole UI based on them.
Since the state usually starts with empty values (like the empty selectedIds
array), it is hard to design the UI without testing using actual values. However, mock values can be used to make testing easier:
// Mock states:state = {
gameStatus: 'playing',
remainingSeconds: 7,
selectedIds: [0, 3, 4],
};// Also test with
gameStatus: 'lost'// And
gameStatus: 'won'
Using this strategy, we do not have to worry about behavior and user interactions (yet). We can focus on just having the UI designed as functions of data and (mock) state.
The key to doing this step correctly is to make sure child components receive only the minimum data that they actually need to re-render themselves in the various states. This is probably the most important statement in the entire article.
We only have one child component, so let’s think about what it needs to render itself. We are already passing down its value from the map call so what else does it need? For example, think about these questions:
- Does the
Number
component need to be aware of theselectedIds
array to figure out whether it is a selected number? - Does the
Number
component need to be aware of the currentgameStatus
value?
I will admit that answering these questions is not as easy as one might think. While you might be tempted to answer yes to them, the Number
component does not need to be aware of both selectedIds
and gameStatus
. It only needs to be aware of whether or not it can be clicked because if it cannot be clicked it will need to render itself differently.
Passing anything else to the Number
component will make it re-render unnecessarily, which is something we should avoid.
We can use a lower opacity to represent a non-clickable number. Let’s make the Number
component receive a clickable
prop.
Computing this boolean clickable
prop should happen in the Game
component to avoid having to pass more data to the Number
component. Let me give examples about the importance of making sure a child component receives only the minimum data that it needs:
- If we pass the
gameStatus
value to theNumber
component, then every time thegameStatus
changes (for example, fromplaying
towon
), given the example numbers we are testing with, React will re-render all six challenge numbers while it did not really need to re-render any of them for that case. A Number component does need to re-render when thegameStatus
changes fromnew
toplaying
because of the masking question marks feature at the beginning. To avoid passing down thegameStatus
toNumber
, we can compute the value displayed in aNumber
component within themap
function callback in theGame
component. - If we pass the
selectedIds
array down to theNumber
component, then on every click React will re-render all six challenge numbers when it only needed to re-render one number. This is why aclickable
boolean flag is a much better choice here.
With every prop you pass to a child React component comes great responsibility.
This is more important than you might think. However, React will not optimize the re-rendering of a component automatically. We will have to decide if we want it to do so. This is discussed in step #8 below.
Besides the clickable
prop, what else does the Number
component need? Since it is going to be clicked and we need to place the clicked number’s ID on the Game
state, the click handler of every Number
component needs to be aware of its own ID (and we cannot use React’s key
prop value there). Let’s make the Number
component receive an id
prop as well.
// In the Number componentrender() {
return (
<div
className="number"
style={{ opacity: this.props.clickable ? 1 : 0.3 }}
onClick={() => console.log(this.props.id)}
>
{this.props.value}
</div>
);
}
The computation of whether a number is available and clickable can use a simple indexOf
call on the selecetdIds
array. Let’s create a function for that:
// In the Game class
isNumberAvailable = (numberIndex) =>
this.state.selectedIds.indexOf(numberIndex) === -1;
One behavior you probably noticed while playing the game above is that the number squares start out displaying a question mark until the Start button is clicked. We can use a ternary operator to control the value of each Number
component based on the gameStatus
value. Here is what we need to change to render a Number
component inside the map
call:
<Number
key={index}
id={index}
value={this.state.gameStatus === 'new' ? '?' : value}
clickable={this.isNumberAvailable(index)}
/>
We can use a similar ternary expression for the target number value and also control its background color using a lookup call to the static bgColors
object:
<div
className="target"
style={{ backgroundColor: Game.bgColors[gameStatus] }}
>
{this.state.gameStatus === 'new' ? '?' : this.target}
</div>
Finally, we should show the Start button only when the gameStatus
is new
and otherwise show the remainingSeconds
counter. When the game is won
or lost
, let’s show a Play Again button. Here are the modifications we need for all that:
<div className="footer">
{this.state.gameStatus === 'new' ? (
<button>Start</button>
) : (
<div className="timer-value">{this.state.remainingSeconds}</div>
)}
{['won', 'lost'].includes(this.state.gameStatus) && (
<button>Play Again</button>
)}
</div>
You can see the full code we have so far here.
Step #6: Designing behaviors to change the state
The first behavior that we need to figure out is how to start the game. We need two main actions here: 1) change the gameStatus
to playing
and 2) start a timer to decrement the remainingSeconds
value.
If remainingSeconds
is decremented all the way to zero, we need to force the game into the lost
state and stop the timer as well (because otherwise, it will decrement beyond zero.)
Here is a function we can use to do all that:
// In the Game classstartGame = () => {
this.setState({ gameStatus: 'playing' }, () => {
this.intervalId = setInterval(() => {
this.setState((prevState) => {
const newRemainingSeconds = prevState.remainingSeconds - 1;
if (newRemainingSeconds === 0) {
clearInterval(this.intervalId);
return { gameStatus: 'lost', remainingSeconds: 0 };
}
return { remainingSeconds: newRemainingSeconds };
});
}, 1000);
});
};
Note how I start the timer only after the setState
call is complete using the second argument function callback to setState
.
Next, let’s figure out what should happen when a number is clicked during a game session. Let’s create a selectNumber
function for that. This function should receive the ID of the clicked number and only work when the gameStatus
is playing
. Every time a number is clicked, we need to add its ID to the selectedIds
array. We also need to compute the new gameStatus
because every click might result in a won
/lost
status. Let’s create a calcGameStatus
function to do that.
Here is one way to implement these two new functions:
// In the Game classselectNumber = (numberIndex) => {
if (this.state.gameStatus !== 'playing') {
return;
}
this.setState(
(prevState) => ({
selectedIds: [...prevState.selectedIds, numberIndex],
gameStatus: this.calcGameStatus([
...prevState.selectedIds,
numberIndex,
]),
}),
() => {
if (this.state.gameStatus !== 'playing') {
clearInterval(this.intervalId);
}
}
);
};calcGameStatus = (selectedIds) => {
const sumSelected = selectedIds.reduce(
(acc, curr) => acc + this.challengeNumbers[curr],
0
);
if (sumSelected < this.target) {
return 'playing';
}
return sumSelected === this.target ? 'won' : 'lost';
};
Note a few things about the functions above:
- We used the array spread operator to append
numberIndex
toselectedIds
. This is a handy trick to avoid mutating the original array. - Since the new
gameStatus
is to be computed while we are updating the state, I passed the newselectedIds
value to thecalcGameStatus
function rather than using the currentselectedIds
value (which has not been updated yet to include the newnumberIndex
at that point). - In
calcGameStatus
, I used areduce
call to compute the current sum after a click using a combination of what is selected and the originalchallengeNumbers
array, which holds the actual values of numbers. Then, a few conditionals can do the trick of determining the current game status. - Since the timer has to be stopped if the new
gameStatus
is notplaying
, I used the second callback argument forsetState
to implement that logic and make sure it will use the newgameStatus
after the asyncsetState
call is done.
The game is currently completely functional with the exception of the Play Again button. You can see the full code we have so far here.
Now, how exactly are we going to implement this Play Again action? Can we simply just reset the state of the Game
component?
Nope. Think about why.
Step #7: Resetting a React component
The Play Again action needs more than a simple reset of the state of the Game
component. We need to generate a new set of challengeNumbers
and target
number. In addition, we need to clear any currently running timers and auto-start the game.
We can certainly improve the startGame
function to do all of that, but React offers an easier way to reset a component: unmount that component and just remount it. This will trigger all initialization code and take care of any timers as well.
We do not really have to worry about the timer part of the state because that part is controlled by behavior. However, in general, unmounting a component should also clear any timers defined in that component. Always do that:
// In the Game classcomponentWillUnmount() {
clearInterval(this.intervalId);
}
Now, if the Game
component is unmounted and re-mounted, it will start a completely fresh instance with new random numbers and an empty state. However, to re-mount a component based on a behavior, we will need to introduce a new parent component for Game
(I will name that App
) and put something on the state of this new parent component (to trigger a UI change).
React has another useful trick we can use to accomplish this task. If any React component is rendered with a certain key
and later re-rendered with a different key
, React sees a completely new instance and automatically unmounts and re-mounts that component!
All we need to do is have a unique game ID as part of the state of the App
component, use that as the key
for the Game
component, and change it when we need to reset a game.
Since we also want the game to auto-start when the player clicks Play Again (instead of having them click Start after Play Again), let’s make the App component also pass down an autoPlay prop to Game and compute that based on the new gameId attribute. Only the first game should not be auto played.
Here are the modifications that we need:
// Create new App componentclass App extends React.Component {
state = {
gameId: 1,
};resetGame = () =>
this.setState((prevState) => ({
gameId: prevState.gameId + 1,
}));render() {
return (
<Game
key={this.state.gameId}
autoPlay={this.state.gameId > 1}
challengeSize={6}
challengeRange={[2, 9]}
initialSeconds={10}
onPlayAgain={this.resetGame}
/>
);
}
}// In the Game class: respect the value of the new autoPlay prop
componentDidMount() {
if (this.props.autoPlay) {
this.startGame();
}
}// In the Game render call
// Wire the Play Again action using the parent prop
<button onClick={this.props.onPlayAgain}>
Play Again
</button>// Render the new App component instead of Game
ReactDOM.render(<App />, document.getElementById('mountNode'));
You can see the full code we now have here.
Step #8: Optimize if you can measure
Wasteful rendering of the components that do not need to be re-rendered is one of the challenging aspects of a React application. We went to great lengths in step #5 to not pass any prop that will cause a Number
component to re-render unnecessarily.
However, the code as it is now is still wastefully re-rendering most of the Number
components. To see this in action, use a componentWillUpdate
method in the Number
component and just console.log
something there:
// In the Number component
componentWillUpdate() {
console.log('Number Updated');
}
Then, go ahead and play. On every state change in the Game
component, you will see that we are re-rendering all 6 Number
components. This happens when we click the Start button and every second after that!
The fact is, a Number
component should not re-render itself unless the player clicks on it. The 60
re-renders that were triggered by the timer change were wasteful. Furthermore, when the player clicks a number, only that number needs to be re-rendered. Right now, React also re-renders all six numbers when the player selects any number.
Luckily, we have been careful enough to only pass to the Number
component the exact props that it needs to re-render. Only the challenge number that needs to be re-rendered will receive different values in these props. This means we can use a conditional in React’s shouldComponentUpdate
to short-circuit the render operation if all nextProps
of a Number
component match the current props.
React’s PureComponent
class will do exactly that. Go ahead and change the Number
component to extend React.PureComponent
instead of React.Component
and see how the problem magically goes away.
class Number extends React.PureComponent
However, is this optimization worth it? We cannot answer that question without measuring. Basically, you need to measure which code uses fewer resources: a component render call or the if
statement in React.PureComponent
that compares previous and next state/props. This completely depends on the sizes of the state/props trees and the complexity of what is being re-rendered. Do not just assume one way is better than the other.
You can see the final code here. MVP complete. Now, for the love of CSS, can someone please style this game to make it appealing to kids? :)
Do not stop here if you like this. Add more features to the game. For example, keep a score for winning and increase it every time the player wins a round. Maybe make the score value depend on how fast the player wins the round. You can also make future rounds harder by changing challengeSize
, challengeRange, and initialSeconds when starting a new game.
The Target Sum game was featured in my React Native Essential Training course, which is available on Lynda and LinkedIn Learning.
Thanks for reading.
I dedicated a big part of my 2017 to writing books which are all now available on Amazon: