In a recent interview with Dan Abramov, Mattias Petter Johansson converted the components in the tic tac toe game used in the React tutorial docs from classes to functions using Hooks. At the end of the interview, Dan gave some suggestions on how Hooks could be used to not only convert the classes into functions, but to improve the overall quality of the code.
In this post, I’ll be giving an introduction to Hooks by converting the tic tac toe tutorial into functions in a similar way to the interview. I’ll be showing how to handle side effects with useEffect
. Finally, I’ll be talking about Dan Abramov’s first suggestion: using the Hook useReducer
to better manage the game’s state.
In a future post, I’ll be showing the real value of Hooks by using Dan’s second suggestion - extracting away stateful logic into a custom Hook which can be reused throughout the app.
Converting from classes to functions - Why do we need hooks?
The original tic tac toe final product can be found here. Currently, we have 3 components:
Square
, which displays the current state of a square on the board. In this case, it will either be X, O, or blank.Board
, which displays the current board. In this case is a group of squares laid out in a 3x3 grid.Game
, which manages the overall game state (e.g. who’s turn it is, who the winner is, etc.). It also stores the history of the game, and allows the players to travel back to previous states of the game board.
The Board
and the Square
components simply take an input in the form of “props” and produce an output in the form of a React element to render on the page. There are no state nor side effects, which makes it very straight forward to convert these from class components to function components.
Things are less straight forward with the Game
component. It contains local state, so in order to convert the component to a function, we will need a way to handle that state.
Our first idea might be to store the state as a local variable. However, when we try this, we find that the component doesn’t re-render when the variable updates. This is because setState
function is not being called. In addition, this would not be taking advantage of the performance gains that React uses on the setState
function by batching the calls into a single update.
So if we can’t just use a local variable, how can we use…state?
useState
As stated in the React docs, “useState
is a Hook that lets you add React state to function components.” For example, to add the stepNumber
variable to the state, you can use the following line:
const [stepNumber, setStepNumber] = useState(0);
The function useState
takes one argument, which is the initial value for the variable being added to state. It returns an array of two items - the current state and a function for updating the current state. The syntax uses array destructuring to assign the items to whatever variable names we choose (the convention is to use [variableName, setVariableName]
).
Using useState
, we can finish converting our Game
component into a function component (For simplicity, only parts of the component are shown. The full file can be seen here).
import React, { useState } from 'react';
import Board from './Board';
import { calculateWinner } from '../utils/calculateWinner';
const Game = () => {
const [history, setHistory] = useState([{
squares: Array(9).fill(null),
}]);
const [stepNumber, setStepNumber] = useState(0);
const [xIsNext, setXIsNext] = useState(true);
const currentBoard = history[stepNumber];
const winner = calculateWinner(currentBoard.squares);
const handleClick = (i) => {
const newHistory = history.slice(0, stepNumber + 1);
const squares = currentBoard.squares.slice();
if (winner || squares[i]) {
return;
}
squares[i] = xIsNext ? 'X' : 'O';
setHistory(newHistory.concat([
{
squares: squares
}
]));
setStepNumber(newHistory.length);
setXIsNext(!xIsNext);
};
...
return (
<div className="game">
<div className="game-board">
<Board
squares={currentBoard.squares}
onClick={i => handleClick(i)}
/>
</div>
<div className="game-info">
<div>{status}</div>
<ol>{moves}</ol>
</div>
</div>
);
};
export default Game;
As you can see, you no longer need to use this.state.history
. Instead, we treat history
like a regular variable, because that’s what it is now.
So now we’ve successfully converted the Game
component into a function component using hooks. But what would happen if you wanted to implement other features that were previously only possible with classes? For example, how would you implement side effects if you don’t have access to lifecycle methods?
useEffect
To see how we can use hooks to handle side effects, let’s implement an API call to load a previously unfinished game. We want this call to be made when the Game
component initially mounts. Using class components, this would be done in the componentDidMount
lifecycle method.
componentDidMount() {
fetchSavedHistory()
.then(savedHistory => setState({ history: savedHistory }));
}
As you will have guessed by the section heading, this can now be done in React using useEffect
. This Hook allows us to perform side effects in our component. As the docs put it, “You can think of useEffect
Hook as componentDidMount
, componentDidUpdate
, and componentWillUnmount
combined.”
So how does it work?
useEffect(() => {
fetchSavedHistory()
.then(savedHistory => setState({ history: savedHistory }));
});
useEffect
takes a function as its first argument and calls that function each time the component renders. So now when our Game
component first renders, it will call this function which will fetch the saved history and load it into the state. Let’s open up our app and see if it has worked.
Success! 🎉
However, once we start playing the game, we run into a problem (some of you may have predicted from my earlier comments what this is and how to fix it).
The function we passed to useEffect
is called after every render. This means that it will be called after the first render (which we want), and then again after every re-render (which happens every time someone makes a move). So every time someone makes a move, the component requests the saved history from the store and reloads it, erasing any moves that have been made.
To solve this problem, useEffect
can optionally take a second argument:
useEffect(() => {
fetchSavedHistory()
.then(savedHistory => setState({ history: savedHistory }));
}, []);
This second argument tells React “to only apply an effect if certain values have changed between re-renders”. This would usually be done in classes by writing a comparison with prevProps
or prevState
inside componentDidUpdate
.
If we pass an empty array, no values within that array will ever change after a re-render, and therefore the effect will only be called on the initial render. This might seem strange to those who are used to the React class lifecycle methods but, as Ryan Florence points out, the correct way to think of the second argument isn’t in terms of which lifecycle method the effect should occur in, but in terms of which state the effect should synchronize with (in this case, no state).
This functionality begins to give us a glimpse of the real benefits of hooks. With classes, the side effects were separated in code by the lifecycle method that calls them. For example, in componentDidMount
, we may be subscribe to an event listener, and then unsubscribe from it in componentDidUnmount
. With hooks, these two related tasks are brought together in the code. As Dan Abramov put it in his “React Today and Tomorrow” talk, “with Hooks we separate code not based on the lifecycle method name but based on what the code is doing.”
useReducer
Looking at the current code, you may have noticed something. When we were using class components, each action which triggered a change to the state would only have to call one function, setState
. Now we have to call multiple functions, which gives the incorrect impression that each of these setFoo
functions are not related.
For example, when a move is made, the updates to history
, stepNumber
and xIsNext
are all part of the same action (i.e. if only one of the variables updated but not the others, the game would be in an invalid state). But each are set in a different function (setHistory
, setStepNumber
, setXIsNext
) which can make them appear separate.
For this reason, Dan’s first suggestion at the end of the interview is to “refactor it to useReducer
to centralise the state handling logic.” useReducer
works in a similar way to Redux, but handles state on a local level instead of at a global level.
useReducer
takes two arguments: a reducer and the initial state. The initial state is straight forward - it’s the same object that we set the state to in the constructor of our component when it was a class component.
const initialState = {
history: [{
squares: Array(9).fill(null),
}],
stepNumber: 0,
xIsNext: true,
};
The reducer is a function of type (state, action) => newState
. It takes a state object and returns a new state object based on the action. In our current application, we have three different action types: make_move
, jump_to
and load_game
.
const reducer = (state, action) => {
switch (action.type) {
case 'make_move':
return {
...state,
history: [...state.history.slice(0, state.stepNumber + 1), action.newBoardState],
stepNumber: state.stepNumber + 1,
xIsNext: !state.xIsNext,
};
case 'jump_to':
return {
...state,
stepNumber: action.step,
xIsNext: (action.step % 2) === 0,
};
case 'load_game':
return {
...state,
history: action.savedHistory,
stepNumber: action.savedHistory.length - 1,
xIsNext: ((action.savedHistory.length - 1) % 2) === 0,
};
default:
throw new Error('Unexpected action');
}
};
We can then call useReducer
. In the same way as useState
, this returns an array which we can destructure to give us the current state and the dispatch function.
Now, whenever an “action” occurs, we simply dispatch that action and let the reducer handle how the state is updated. In other words, we have decoupled the expressing of “actions” that occur in our component and how the state updates in response to them. For example, the jumpTo
function now becomes:
const [state, dispatch] = useReducer(reducer, initialState);
...
const jumpTo = (step) => {
dispatch({ type: 'jump_to', step });
};
Conclusion - But what’s the point?
And we’re done! We’ve successfully managed to convert our class components into functions, handled side effects, and decoupled our “actions” from the state updates. The final result can be found here.
But now you may be wondering, “But why did we have to go through all this trouble? We could already use features such as state and side effect with class components.”
While there are advantages of being able to use functions instead of classes, the real benefits of Hooks come from being able to extract stateful logic out of the component into a reusable function called a custom hook. In the words of Dan Abramov, “Custom Hooks are, in our opinion, the most appealing part of the Hooks proposal.”
In our Game
component, you’ll notice that the component has 2 roles: handling the logic of the tic-tac-toe game and handling the time travel logic. In a future post, we’ll implement Dan Abramov’s second suggestion: extracting out the time travel logic into a custom hook. This will allow us to use time travel on any other game we may wish to implement in the future without the need for repeated code.