Learn React basics by creating a simple calculation game (part 3)

Jan 7, 2021 · React, simple calculation game, useReducer · 10 minute read

This is part 3 of a series of blog posts where I explore basic React principles by building a simple calculation game. In this blog post I will improve the app I created in Part 1 and Part 2. You might want to read those first, so you know what the app is about and how it was built.

Or check out the demo on Netlify. In this blog post we'll create version 3.

In this blog post we'll hardly change the functionality, instead, we take another approach to managing state in our component by using the useReducer hook. I'll explain how to do this and why this might improve your React component code.

At the moment, the top of the App component looks like this, we have 3 useState calls for 3 separate state variables:

function App() {
  const [question, setQuestion] = useState({ choices: [] });
  const { answer, choices } = question;
  const [selected, setSelected] = useState([]);
  const [result, setResult] = useState();
  /* ... */

The variables that useState returns, both the current state value and the setter functions, are used throughout the component. When a component becomes bigger and/or more complex, you might lose track of where, why and how state is updated.

Switching from using various useStates to one useReducer hook is one of the approaches to solve this problem. Because useReducer is an alternative for useState, switching to useReducer most of the times is a refactor and not necessarily a change in functionality, which is also the case in this blog post.

The arguments to useReducer

Before calling useReducer in the component, we'll create variables that we need to pass to useReducer.

First there is a reducer function, which I'll call appReducer, but you can name it anything you like. More on what this function does later on.

Next there is the initial state. In our case we do have some initial state, so we create an object that contains all initial state that is needed for our component and until now was passed to useState.

Finally we call useReducer inside the component and pass the 2 variables we just created:

function appReducer() {}

const initialState = {
  question: { choices: [] },
  selected: [],
  result: null,
};

function App() {

  useReducer(appReducer, initialState)
  //const [question, setQuestion] = useState({ choices: [] });
  //const { answer, choices } = question;
  //const [selected, setSelected] = useState([]);
  //const [result, setResult] = useState();
  /* ... */

Now is a good time te delete all commented out useState calls too, because we don't need them anymore.

Although we still have to implement appReducer, we are finished passing all necessary arguments to useReducer. Now let's focus on what useReducer returns.

What useReducer returns

Just like useState, useReducer returns an array with two items: The first one is the current state, an object that contains all state variables. When we still used useState, these were separate variables: question, selected and result.

The second item of the array returned by useReducer is the dispatch function. It's the function we'll later call to update state.

Notice how both useState and useReducer return an array with two items which have a similar goal, although the usage of these items differs, as you will see soon.

function App() {
  const [state, dispatch] = useReducer(appReducer, initialState)
  /* ... */

state is an object containing all state variables, so instead of only destructuring question, now let's destructure the whole state, so all code that uses the separate state values does not break:

function App() {
  const [state, dispatch] = useReducer(appReducer, initialState)
  const { question: { answer, choices }, selected, result } = state;
  /* ... */

We finished calling useReducer, but our code is broken now, because all the setter functions we used to receive from the useState hook (setQuestion, setSelected and setResult) are gone now.

Replacing useState setters by dispatch

Now we will replace all useState setter calls with useReducer's dispatch calls. And along with it we will implement our appReducer function which will perform the actual state updates.

Let's start with a state update that really shows where using useReducer shines, and that is the reset function:

function reset() {
  setSelected([]);
  setResult(null);
}

It has two calls to set different state variables. We will replace this by one dispatch call. And that is because selected and result are now part of a single state object.

When calling dispatch you always pass a so-called action object, which at least always contains the action type. The action type is a string value that indicates what you want to change about the state. By convention these are in upper case, but you can use any casing you like. Let's call it "RESET", just like the name of the function:

function reset() {
  //setSelected([]);
  //setResult(null);
  dispatch({ type: "RESET" });
}

Notice that we went from calling setter functions that exactly described how to change the state to a dispatch call that only describes what you want to change about the state. This is the big difference between useState and useReducer.

Now that we told React to "RESET", let's implement our reducer to actually do that.

First we change the signature of our reducer to make it adhere to what React expects from a reducer by adding the two required arguments state and action:

function appReducer(state, action) {
  //TODO reducer implementation
}

Now you might think: Who is going to call this function? Because we don't, we just call dispatch.

What happens is that when we call dispatch, React will call appReducer for us and will pass the two required arguments we just added: First the current state, which is something React is keeping track of. And the second argument is action, which is the action object we passed to the dispatch. For the reset call it only contains a type string, but it can contain more as you will see later on.

Let's implement appReducer so it resets the appropriate state variables.

What a reducer always returns is a new state object. State should always considered to be immutable: don't change the incoming state directly, but instead return a new object.

React uses immutable state to detect whether a state change occurred and a re-render is necessary without having to do any comparison of the previous and current state's data: No matter what exactly has changed, it is new, so just re-render.

A way to return a new object based on another object is to use the spread operator:

function appReducer(state, action) {
  return { ...state, selected: [], result: null };
}

What this return statement says is: Return the state as it was, but make selected an empty array and set result to null.

Note how we did not use the action argument yet, because this is the only state change our reducer does until now. However, there will be more action types so let's prepare our reducer for that by adding a switch statement:

function appReducer(state, action) {
  switch (action.type) {
    case "RESET":
      return { ...state, selected: [], result: null };
    default:
      return state;
  }
}

I also added a default which returns the current state so our reducer at least always returns something.

We have finished our first state change with useReducer.

On to the next one, the done function, which is determining whether the given answer is correct:

function done() {
  const selectedTotal = selected.reduce((a, b) => a + b, 0);
  selectedTotal === answer ? setResult("correct!") : setResult("incorrect...");
}

Here you have to decide what goes to the reducer and what stays in the done function. However, this function is only using the current state to do some checking and there is no reason to keep it here. Next to updating state, a reducer's responsibility can also be to take care of the UI logic of your component:

function done() {
  dispatch({ type: "ANSWER_QUESTION" });
}

And the reducer implementation, where I ommitted the other action type implementations:

function appReducer(state, action) {
  switch (action.type) {
    /* ... */
    case "ANSWER_QUESTION": {
      const selectedTotal = state.selected.reduce((a, b) => a + b, 0);
      const result =
        selectedTotal === state.question.answer ? "correct!" : "incorrect...";
      return { ...state, result };
    }
    /* ... */
  }
}

Passing additional action data to a reducer

Next, let's tackle selecting and deselecting a number:

function select(number) {
  setSelected([...selected, number]);
}

function deselect(number) {
  const index = selected.indexOf(number);
  if (index === -1) return;
  const newSelected = [...selected];
  newSelected.splice(index, 1);
  setSelected(newSelected);
}

For both functions we will move the full implementation to the reducer.

But implementing the reducer for these functions is kind of special: Next to the action type, we need to tell the reducer the number that was clicked. Therefore we also supply the action payload, an object that contains the number:

function select(number) {
  dispatch({ type: "SELECT", payload: { number } });
}

function deselect(number) {
  dispatch({ type: "DESELECT", payload: { number } });
}

And this is the reducer:

function appReducer(state, action) {
  switch (action.type) {
    /* ... */
    case "SELECT":
      const newSelected = [...state.selected, action.payload.number];
      return { ...state, selected: newSelected };
    case "DESELECT": {
      const index = state.selected.indexOf(action.payload.number);
      if (index === -1) return state;
      const newSelected = [...state.selected];
      newSelected.splice(index, 1);
      return { ...state, selected: newSelected };
    }
    /* ... */
  }
}

What you put in the payload, a numeric value, an object, etc. is up to you. You can even call it data, or whatever, instead of payload. As long as your reducer can handle it. However, using payload with an object is a convention that is used most often and therefore is recommended.

Calling dispatch in a useEffect

Finally, we'll implement getting a new question after the previous one was answered. This is handled by a useEffect:

useEffect(() => {
  if (!result) setQuestion(getQuestion());
}, [result]);

Let's replace setQuestion by a dispatch with an action type of "NEW_QUESTION" and putting the new question in the payload:

useEffect(() => {
  if (!result) {
    const question = getQuestion();
    dispatch({ type: "NEW_QUESTION", payload: { question } });
  }
}, [result]);

Note how the useEffect is still responsible for getting a new question, because that is still a side effect. However, updating the question state will be handled by the reducer:

function appReducer(state, action) {
  switch (action.type) {
    /* ... */
    case "NEW_QUESTION":
      return { ...state, question: action.payload.question };
    /* ... */
  }
}

What did we gain with using useReducer?

We finished switching from useState to useReducer. You might think, using useReducer is quite complicated. Well, it's getting used to I think.

The big change we made in our component is that the component tells which state change it wants. And not, like we did with useState, how exactly the state should change. And this is particularly interesting for components that have a complex state structure and/or a lot of places where the state changes.

However, there are people who always and only use useReducer, just to keep components more clean. It's up to you whether you think this is a good approach.

Until now, we did not change the functionality of our app, it was just a straight up refactor.

But when using useReducer, adding new functionality can also have a smaller impact on your component code, because there is less going on. To demonstrate this, I will add a score which keeps track of the number of correctly answered questions.

First we introduce a new state variable score in the component by destructuring it from state and render it in the JSX:

function App() {
  const [state, dispatch] = useReducer(appReducer, initialState)
  const { question: { answer, choices }, selected, result, score } = state;

  /* ... */

  return (
    <div className={styles.container}>

      /* ... */

      <div className={styles["full-width"]}>
        Score: {score}
      </div>

      /* ... */

Finally, we add it to the initialState with value 0 and in the reducer increase the score when the answer is correct:

function appReducer(state, action) {
  switch (action.type) {
    /* ... */
    case "ANSWER_QUESTION": {
      const selectedTotal = state.selected.reduce((a, b) => a + b, 0);
      if (selectedTotal === state.question.answer) {
        return { ...state, result: "correct!", score: state.score + 1 };
      } else {
        return { ...state, result: "incorrect..." };
      }
    }
    /* ... */
  }
}

See how we hardly changed the component code, because it doesn't contain any logic regarding state. The real magic is going on in the reducer.

Next to cleaner components with less complexity and more focus on the actual UI, components also become smaller. This is already the case with how my code changed in this blog post, but we can even go a step further.

Scroll up to see how we do all the dispatch calls inside functions which are called from the onClick events in the JSX. We could get rid of all these functions and call dispatch from the onClick events directly. This is also something that depends on your personal preference, but it can make components more concise.

Wrapping up

That's it for useReducer, I hope you liked reading it as much as I did writing it.

I am not yet sure whether there will be a next post in this series and where it will be about. Two subjects I'd like to learn and blog about are React Testing Library and Tailwind CSS, so I might combine that with this app.

The source code for this app is on my GitHub: https://github.com/bouwe77/react-simple-calculation-game/blob/main/src/simple-calculation-game/v3/App.js

Share on Twitter · Discuss on Twitter · Edit on GitHub