alphawaffle | blog

7/6/2023

Mutating State in React

In this article I’m going to introduce the useState hook in React, how it’s used, and some common pitfalls that can happen when using it. You’ll likely want to know a bit about JavaScript and React before reading, but if you don’t I’ve included some links throughout the article to provide some extra info about some of the more advanced concepts.

Intro to useState

The useState hook is usually one of the first hooks a React developer learns about and while it maybe seems straightforward, using it in real-world scenarios can be less than intuitive.

Here’s an example of how the hook is used in a component:

import { useState } from "react";

function Counter() {
  const [counter, setCounter] = useState(0);
}

In this example we’re importing in the useState hook from react, calling it within a component and with an argument of 0, and assigning it’s return value to two values: counter and setCounter. Let’s break that down a bit more.

Why call it with an argument?

The 0 that we’re passing into useState is what we want the initial value of the state to be. It could be any value, but we’re choosing 0 here because we’re making a counter and, well, we want the counter to start at 0. If we wanted the counter to start at 10 we could pass in 10 instead. If we wanted to pass in an object we could do that too:

import { useState } from "react";

function Counter() {
  const [counter, setCounter] = useState({ count: 0 });
}

What’s up with the array syntax?

If this syntax seems a bit weird it’s because we’re using array destructuring. Basically, it’s a quick way of doing this:

import { useState } from "react";

function Counter() {
  const counterState = useState(0);
  const counter = counterState[0];
  const setCounter = counterState[1];
}

The return value of useState is an array with two items. The first item is the current value of the state and the second item is a function that we can use to update the state. We’re using array destructuring to assign the first item to counter and the second item to setCounter.

What’s the deal with the naming convention?

The naming convention used here is an easy way to keep track of what we’re doing with the state. The first item in the array is the current value of the state, so we’re calling it counter. The second item in the array is a function that we can use to update the state, so we’re calling it setCounter. This naming convention is not required, but it’s a good idea to use it to keep track of what you’re doing with the state.

Why use useState?

State is the cornerstone of interactivity in React. It’s how we keep track of what’s happening in our application and it’s how we update the UI when something changes. The useState hook is the most common way to manage state in React components.

Our counter component is a good example of how we can use state to keep track of what’s happening in our application. We can use the counter variable to display the current value of the counter and we can use the setCounter function to update the counter when the user clicks on a button.

import { useState } from "react";

function Counter() {
  const [counter, setCounter] = useState(0);

  return (
    <div>
      <p>{counter}</p>
      <button onClick={() => setCounter(counter + 1)}>Increment</button>
    </div>
  );
}

Without state, we wouldn’t be able to keep track of the current value of the counter and we wouldn’t be able to update the UI when the user clicks on a button.

Mutating State

Now that we have a basic understanding of how useState works, let’s look at some common pitfalls that can happen when using it.

Mutating State Directly

One of the most common pitfalls when using useState is mutating state directly. Let’s look at an example:

import { useState } from "react";

function Counter() {
  const [counter, setCounter] = useState(0);

  const increment = () => {
    counter++;
  };

  return (
    <div>
      <p>{counter}</p>
      <button onClick={increment}>Increment</button>
    </div>
  );
}

In this example we’re creating a function called increment that increments the counter variable. We’re then calling that function when the user clicks on the increment button. If we run this code we’ll see that the counter doesn’t increment when we click on the button. Why is that? 🤔

The reason is that we’re mutating state directly. When we call increment we tell React that we want to update the state. React then re-renders the component, which means that the counter variable is set back to 0. This is because the counter variable is set to the value of the state, which is 0. If we want to increment the counter we need to call setCounter with the new value.

import { useState } from "react";

function Counter() {
  const [counter, setCounter] = useState(0);

  const increment = () => {
    setCounter(counter + 1);
  };

  return (
    <div>
      <p>{counter}</p>
      <button onClick={increment}>Increment</button>
    </div>
  );
}

Now when we click on the button the counter increments as expected. This is because we’re calling setCounter with the new value of the counter, which tells React that we want to update the state.

How does React update state?

React uses a technique called state reconciliation to update the UI, but what does that really mean? To understand this, we’ll need to peek under the hood of React a bit.

When we interact with a React application we aren’t directly interacting with the Document Object Model (DOM) , instead, we’re interacting with a virtual representation of the DOM. This virtual representation is called the virtual DOM.

When we call setCounter with a new value React creates a new virtual DOM that reflects this change. React then compares the new virtual DOM to the old virtual DOM and only updates the parts of the UI that have changed. This is why we need to call setCounter with the new value of the counter. If we don’t call setCounter with the new value of the counter React won’t know that we want to update the state and it won’t re-render the component.

Mutating State of Objects and Arrays

Another common pitfall when using useState is mutating state of objects and arrays. Let’s look at an example:

import { useState } from "react";

function MovieList() {
  const [movies, setMovies] = useState([]);

  const addMovie = () => {
    movies.push("The Godfather");
  };

  return (
    <div>
      <ul>
        {movies.map((movie) => (
          <li>{movie}</li>
        ))}
      </ul>
      <button onClick={addMovie}>Add Movie</button>
    </div>
  );
}

In this example we’re creating a function called addMovie that adds a movie to the movies array. We’re then calling that function when the user clicks on the add movie button. Similarly to our previous example, if we run this code we’ll see that the movie doesn’t get added to the list when we click on the button. Why is that? 🤔

Once again, the reason is that we’re mutating state directly. This time, we’re trying to push to the movies array. In JavaScript, arrays and objects are reference types. This means that when we try to push to the movies array we’re mutating the state directly.

We’ll need to find a different way to add a movie to the movies array. Luckily, we can use a tricky little thing called the spread operator to add a movie to the movies array without mutating the state directly.

import { useState } from "react";

function MovieList() {
  const [movies, setMovies] = useState([]);

  const addMovie = () => {
    setMovies([...movies, "The Godfather"]);
  };

  return (
    <div>
      <ul>
        {movies.map((movie) => (
          <li>{movie}</li>
        ))}
      </ul>
      <button onClick={addMovie}>Add Movie</button>
    </div>
  );
}

Now when we click on the button the movie gets added to the list as expected. This is because we’re calling setMovies with a new array that contains the movies from the old array and the new movie. This tells React that we want to update the state and it re-renders the component with the new movie in the list. But, what if we want to remove a movie from the array? 🤔

We can use the filter method to remove a movie from the array. Let’s look at an example:

import { useState } from "react";

function MovieList() {
  const [movies, setMovies] = useState([]);

  const addMovie = () => {
    setMovies([...movies, "The Godfather"]);
  };

  const removeMovie = (movie) => {
    setMovies(movies.filter((m) => m !== movie));
  };

  return (
    <div>
      <ul>
        {movies.map((movie) => (
          <li>
            {movie} <button onClick={() => removeMovie(movie)}>Remove</button>
          </li>
        ))}
      </ul>
      <button onClick={addMovie}>Add Movie</button>
    </div>
  );
}

In this example we’re creating a function called removeMovie that removes a movie from the movies array. We’re then calling that function when the user clicks on the remove button. If we run this code we’ll see that the movie gets removed from the list when we click on the button. This is because we’re calling setMovies with a new array that contains all the movies from the old array except for the movie that we want to remove. This tells React that we want to update the state and it re-renders the component with the movie removed from the list.

Ok, so now we know how to add and remove movies from the list. But, storing movies as strings in an array is pretty simplistic. What if we want to store more information about the movies? We’d want to store an array of objects instead of an array of strings. Let’s look at an example:

import { useState } from "react";

function MovieList() {
  const [movies, setMovies] = useState([]);

  const addMovie = () => {
    setMovies([
      ...movies,
      {
        title: "The Godfather",
        year: 1972,
        rating: 10,
      },
    ]);
  };

  const removeMovie = (movie) => {
    setMovies(movies.filter((m) => m !== movie));
  };

  return (
    <div>
      <ul>
        {movies.map((movie) => (
          <li>
            {movie.title} ({movie.year}) - {movie.rating}{" "}
            <button onClick={() => removeMovie(movie)}>Remove</button>
          </li>
        ))}
      </ul>
      <button onClick={addMovie}>Add Movie</button>
    </div>
  );
}

This makes more sense, we’re doing basically everything the same as we were in the previous example, however we’re storing some extra information about the movies. What if we want to let the user update the rating of a movie? 🤔

We can use the map method to update the rating of a movie. Let’s look at an example:

import { useState } from "react";

function MovieList() {
  const [movies, setMovies] = useState([]);

  const addMovie = () => {
    setMovies([
      ...movies,
      {
        title: "The Godfather",
        year: 1972,
        rating: 10,
      },
    ]);
  };

  const removeMovie = (movie) => {
    setMovies(movies.filter((m) => m !== movie));
  };

  const updateRating = (movie, rating) => {
    setMovies(
      movies.map((m) => {
        if (m === movie) {
          return {
            ...m,
            rating,
          };
        }
        return m;
      })
    );
  };

  return (
    <div>
      <ul>
        {movies.map((movie) => (
          <li>
            {movie.title} ({movie.year}) - {movie.rating}{" "}
            <button onClick={() => removeMovie(movie)}>Remove</button>
            <button onClick={() => updateRating(movie, movie.rating + 1)}>
              +
            </button>
            <button onClick={() => updateRating(movie, movie.rating - 1)}>
              -
            </button>
          </li>
        ))}
      </ul>
      <button onClick={addMovie}>Add Movie</button>
    </div>
  );
}

In this example we’re creating a function called updateRating that updates the rating of a movie. We’re then calling that function when the user clicks on the plus or minus button. If we run this code we’ll see that the rating of the movie gets updated when we click on the plus or minus button. This is because we’re calling setMovies with a new array that contains all the movies from the old array except for the movie that we want to update.

Conclusion

React’s useState hook is an integral and powerful part of React. It’s important to know not just how to use it, but also how it works under the hood. Hopefully this article has helped you understand how useState works and how to use it in your own projects. If you have any questions or feedback please feel free to reach out to me.

avatar

Anthony Miller

Software engineer