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.