Skip to content

React useState, the tricky parts

A beginner's guide to the useState hook

Dec 14, 2020 · post_to_read

If React was an avocado, state would be the shiny immutable stone at its core.

For such an intrinsic part of React, state can often feel pretty unintuitive. Take a look at Stack Overflow's reactjs tag and you'll see that most are related to state in some way.

Time to clear up the confusion, once and for all!

We're going to look at how to solve some common problems with React's useState hook.

Here's the component we'll be using as an example:

import React, { useState } from "react"

const Component = () => {
  const [myNum, setMyNum] = useState(1)

  const handleClick = () => {
    setMyNum(2)
  }

  return (
    <div>
      <div>{myNum}</div>
      <button onClick={handleClick}>Click</button>
    </div>
  )
}

export default Component

Follow along with the examples using this CodeSandbox.

Let's do it!


Mutating state

It seems like the most prevalent struggle new developers face is accidentally mutating state. State in React is designed to be immutable. Updating state doesn't change the old state value - it creates a new value instead.

There are lots of benefits to immutable state which we won't dive into, but it boils down to state change being a whole lot easier to detect when state is immutable. Detecting state change is pretty important, so it makes sense to make it as easy as possible.

Every time you create some state uing the useState hook, the hook gives you back a state variable, and a function that allows you to set that variable:

// myNum is the state variable
// setMyNum is the function used to set myNum
const [myNum, setMyNum] = useState(1)

Think of it almost like a getter and setter - myNum is like a getter that gives you the value, setMyNum is the setter where you pass a new value.

In other words, you don't want to be setting state directly:

// Woops, this is bad
myNum = 2

Instead, pass the new state value to the setter:

// That's better
setMyNum(2)

The const keyword protects us a little here, because you can't set a state variable directly if it's a constant. However, objects and arrays can still be mutated.

For example, if the state value was an array, I can still do this:

// Uh oh, const didn't save us here
myStateArray.push(1)

Let's see what happens to our app when we do mutate state. Do something silly and change const to let in our example:

let [myNum, setMyNum] = useState(1)

Before we change anything else, test that the code works. Does clicking the button change the number from 1 to 2? Good, now let's break everything.

Refresh the CodeSandbox page so the state value returns to 1, and change the handleClick function to this:

const handleClick = () => {
  myNum = 2
}

Now click the button. What happened?

It looks like nothing happened, but actually the value of myNum is now 2. The reason we can't see it is because the component didn't re-render.

React re-renders when state changes, but only if we set state using the setter function. By setting the state variable directly we've skipped some important steps that happen behind the scenes, React doesn't know it needs to re-render, and the user thinks the app doesn't work.

Not good.

To resolve this, change the handleClick function back to:

const handleClick = () => {
  setMyNum(2)
}

Now we know that mutating state is bad, let's never do it again. Go ahead and change let back to const.


Stale state

Another common bug is using stale (outdated) state to set new state.

What makes this so tricky is that most of the time, state change works as expected, luring developers into a false sense of security. When this bug finally rears its ugly head and breaks your app, it's difficult to know where to begin.

Let's take a look.

Update the CodeSandbox example, so that every time we click the button, the number increments by 1:

const handleClick = () => {
  setMyNum(myNum + 1)
}

This works just fine, but there's a bug hidden here somewhere!

Let's increment the number by 2 instead, and we'll do it by duplicating the line that calls setMyNum:

const handleClick = () => {
  setMyNum(myNum + 1)
  setMyNum(myNum + 1)
}

The code looks good, but wait - the number still increments by 1.

Why?

When we make a call to setMyNum we're not actually setting myNum, but asking React to do it for us. React doesn't do this straight away, but registers the update to take place later. Updating state works like this so that updates can be batched and our app re-renders as infrequently as possible.

Changes to the state value only take effect after React re-renders. This means that if myNum has the value 1 at the start of a function, myNum will still be 1 at the end of the function, no matter how many times we call setMyNum. This also makes our state reliable - we can depend on the value not changing until the next render.

Now that we know myNum doesn't change its value while the handleClick function is still running, we can see that we're basically doing this:

const handleClick = () => {
  // myNum starts as 1
  setMyNum(1 + 1)
  setMyNum(1 + 1)
}

To make myNum increment by 2, pass a callback function instead of a new state value to setMyNum. This callback function will be executed once React is ready to set the new state, and React will pass the most recent state value to this function.

That means we can do this:

const handleClick = () => {
  setMyNum(prev => prev + 1)
  setMyNum(prev => prev + 1)
}

Make the above changes to the CodeSandbox and you'll see the number now increments by 2.

We can define the callback function however we like, but it must always take the previous state value and return the new state value. I name the previous state value prev to make it clear that this is the previous and most up-to-date value.

It might seem like we can live without this callback function and achieve the result we want in other ways (e.g. calling setMyNum(myNum + 2)), but as components grow in complexity and the responsibility of setting state is spread throughout the app, you don't want to get caught out with this.

If you're ever unsure whether to set state using a callback function, remember that if new state depends on old state, pass a function to set state.


Mutating state inside setState

Passing a function to setState doesn't make our code bulletproof. We still have to be careful not to mutate state inside the function.

If our state is a value type, the value gets copied when passed to the callback function, and this means we can mutate prev without effecting the underlying state, as prev is a brand-new object.

For example, let's change our CodeSandbox to the following:

const handleClick = () => {
  // prev is a number (value type), so is a copy of the previous state
  setMyNum(prev => {
    // Mutating prev is okay, we increment it first, and then return it
    return ++prev
  })
}

This works fine and causes a re-render because a brand-new object (the copy) is set as the new state value, and we never mutate the previous value.

However, if our state is a reference type such as an object or array, the reference (not the value) gets passed to the callback. This means that assigning to prev mutates the underlying state object! Oh nooo!

Let's change our code to store an object in state:

const [myObj, setMyObj] = useState({ myNum: 1 })

We've given our object a property myNum, so we should change our return statement to:

return (
  <div>
    <div>{myObj.myNum}</div>
    <button onClick={handleClick}>Click</button>
  </div>
)

Finally, change the handleClick function to:

const handleClick = () => {
  setMyObj(prev => {
    // uh oh, we're mutating the previous state object
    prev.myNum = 5
    return prev
  })
}

Clicking the button doesn't re-render the app.

As we saw earlier, React only re-renders when the value of state changes, regardless of whether we call setMyObj or not. When dealing with reference types, React only considers state to have changed if the state value is a new object. This means that returning the same object, even with different properties, will cause React to think that nothing has changed.

In either case, play it safe and never mutate prev.

We can fix our handleClick function like this:

const handleClick = () => {
  setMyObj(prev => ({
    // spread the previous state value into a new object
    ...prev,
    myNum: 5,
  }))
}

We create a new state object, and use spread syntax to 'spread' all the previous object's properties into it. We then set the property that we want to update, and return the new object.

Spread syntax only creates a shallow copy, so nested arrays and objects will need to be spread individually if you're updating values that are deeply nested.

It's important to keep track of the previous value so that we don't accidentally mutate it, and in larger functions this can be difficult. That's why consistent naming of the previous state value (such as calling it prev) is helpful. If I see that I'm mutating prev, then I'm more than likely doing something wrong.


Hopefully this post has helped to make the tricky areas of state management more understandable.