Skip to content

State it, don't mutate it

Avoiding stale and mutated state in React

Mar 15, 2020 post_updated · 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 for new developers. Take a look at Stack Overflow's reactjs tag and you'll see the majority of questions are issues related to setting state.

It's time to clear up the confusion, once and for all! We're going to look at some recurring issues on Stack Overflow, and talk about how to solve them.

We'll use this component to demonstrate these issues, and modify it as we go:

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

I'm using functional components with hooks instead of class components because they do the same job in less code, and this is the direction React is heading. Follow along with the examples using this CodeSandbox.

Let's do it!


Mutating state

It seems that the most prevalent struggle new React developers face is resisting the temptation to mutate state. State in React is designed to be immutable. Updating state doesn't change the old state value - it creates a new one instead.

There are several benefits to immutable state which we won't go 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, and is probably the reason you're using React in the first place... right?

With every state variable created through the useState hook, a function is created that should be used to set that state variable:

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

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

Something that I see a lot of is the use of the assignment operator to set myNum:

// Woops - this is bad
myNum = 2

// That's better
setMyNum(2)

One of the benefits to the useState hook compared to state in a class component is that we tend to declare the hook state variable as a constant, which prevents us from mutating it by, for example, writing myNum = 2.

With class components, we don't have this safety net as this.state is an object with mutable properties, making it just too easy to write something like this.state.myNum = 1234567890.

A state object or array stored as a constant still allows its properties (or elements) to be mutated, so watch out for this.

So that we can see what happens to our app when we do mutate state, let's 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 we change state, but only if we set state using the setter function. By setting the state variable directly we've skipped some important internal steps, React doesn't know it needs to re-render, and to the user it looks like the app doesn't work.

Not good.

To resolve this we can 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

An incredibly common error is using stale (outdated) state to set new state.

What makes this such a deceptive bug is that most of the time the 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 isn't even considered as a possible source of the issue.

To demo this bug, 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, so where's the error?

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, we 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 delegated 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.

Now go forth and set state!