DEV Community

Cover image for Better ReactJS patterns: this.setState pitfalls
Promise Tochi
Promise Tochi

Posted on • Updated on

Better ReactJS patterns: this.setState pitfalls

There's a potential problem with passing object literals to setState like below,

this.setState({someKey: someValue})
Enter fullscreen mode Exit fullscreen mode

The code snippet below illustrates the potential problem. We called setState three times in quick succession, and added a callback to log the updated state to the console after each call.


state = {
  counter: 0
}

incrementCounter = () => {
  this.setState(
    {
      counter: this.state.counter + 1
    },
    () => console.log()
  )
}

componentDidMount = () => {
  incrementCounter()
  incrementCounter()
  incrementCounter()
}

//output

{counter: 1}
{counter: 1}
{counter: 1}


Enter fullscreen mode Exit fullscreen mode

You might have expected the output to be:

{counter: 1}
{counter: 2}
{counter: 3}
Enter fullscreen mode Exit fullscreen mode

There are two reasons for the unintended output:

  1. Asynchronous updates
  2. Batched updates

Reacts asynchronous update can best be described with the code snippet below:

state = {
  counter: 0
}

incrementCounter = () => {

  this.setState(
    {
      counter: this.state.counter + 1
    }
  )

  console.log(this.state.counter) //this will always return the state before the new state above is reflected in state
}

incrementCounter() // 0
incrementCounter() // 1
Enter fullscreen mode Exit fullscreen mode

Instead of logging 1, the initial call to incrementCounter logs 0, second call logs 1 instead of 2, and it continues like that.

Batched updates is described in the official docs with the code sample below,

Object.assign(
  previousState,
  {quantity: state.quantity + 1},
  {quantity: state.quantity + 1},
  ...
)
Enter fullscreen mode Exit fullscreen mode

So our initial code snippet is actually transformed into something like this,

Object.assign(
  previousState,
  {counter: state.counter + 1},
  {counter: state.counter + 1},
  {counter: state.counter + 1})
Enter fullscreen mode Exit fullscreen mode

So how do you avoid these potential issues, by passing a function to setState rather than an Object.

incrementCounter = () => {
  this.setState((presentState) => (
    Object.assign({}, presentState, {
      counter: presentState.counter + 1
    })
  ))
}

componentDidMount = () => {
  incrementCounter()
  incrementCounter()
  incrementCounter()
}

//output

{counter: 3}
{counter: 3}
{counter: 3}
Enter fullscreen mode Exit fullscreen mode

This way, the setState method will always pass an up-to-date state to the function. Notice that we use Object.assign to create a new object from the presentState.

Note that you shouldn't do this,

this.setState((presentState) => {
  presentState.counter+= 1
  return presentState
})

Enter fullscreen mode Exit fullscreen mode

Though the above will cause an update to state and re-render, the snippet below won't, due to React's shallow comparison.

state = {
  someProp: {
    counter: 0
  }
}
this.setState((presentState) => {
  presentState.someProp.current += 1
  return presentState
})
Enter fullscreen mode Exit fullscreen mode

It's still safe to pass setState an object literal when the new state doesn't depend on the old state, but passing it functions instead is a better pattern. If you are familiar with Redux, it's similar to Redux's reducers.

You might have noticed my use of arrow functions with incrementCounter method. It's the proposed es7 property initializer syntax, you can use it now with the babel transform-class-properties plugin.

Cheers.

Top comments (2)

Collapse
 
todorpr profile image
Todor Prikumov

Actually you can do:

incrementCounter = () => {
  this.setState((presentState) => (
    { ...presentState, counter: ++presentState.counter }
  ))
}
Collapse
 
promisetochi profile image
Promise Tochi

You are right, thanks for catching that