loading...
Cover image for Better ReactJS patterns: this.setState pitfalls

Better ReactJS patterns: this.setState pitfalls

promhize profile image Promise Tochi Updated on ・2 min read

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

this.setState({someKey: someValue})

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}


You might have expected the output to be:

{counter: 1}
{counter: 2}
{counter: 3}

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

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},
  ...
)

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})

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}

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
})

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
})

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.

Discussion

markdown guide
 

Actually you can do:

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

You are right, thanks for catching that