Duncan Leung
Avoiding React setState() Pitfalls
Published on

Avoiding React setState() Pitfalls

Authors

setState() can be easily misunderstood in React, and considering that managing component state is a fundamental aspect of React, I wanted to understand the common pitfalls and solutions around using setState().


First, a quick overview of setState() and its behavior.

setState() Signature:

setState(updater, [callback])

setState() takes two arguments.

  • An updater
    • Either an object literal OR a function.
  • An optional callback
    • setState() is asynchronous.
    • The callback is called when the Component state has actually updated.

Passing updater an Object Literal

  • Use the object literal pattern for simple state updates.
  • Passing an object literal is succinct.
// Using an object literal in setState

this.setState({
  selectedLang: 'Javascript',
})

Passing updater a Function

  • Use the updater function pattern when updates need to reference the previous state.
  • Passing an updater function provides access to prevState and current props.
  • prevState is a reference to the previous state. It should not be directly mutated.

updater Function Signature:

(prevState, props) => stateChange

The updater function should build a new object based on the input from prevState and props

// Using an updater function in setState to build a new object

this.setState((prevState, props) => {
  return { counter: prevState.counter + props.increment }
})

setState() Behavior:

When setState() is called it does two main things:

1 - Queues changes to the component state (it is asynchronous)

  • Note: If an object literal is passed as an updater, React first merges the object you passed to setState() into the current state.

2 - Tells React that the component (and children) needs to be re-rendered with the updated state.

  • React's reconciliation process

    • Create a new React Element tree (an object representation of the UI).
    • Diffs the new tree against the old tree.
    • Determines what changed, based on the updater passed to setState().
    • Updates the DOM.

Note: Use React's lifecycle methods to run code at different stages in reconciliation

  • shouldComponentUpdate: Allows determining if the component should update itself by inspecting the previous and new state.
    • If return false, then componentWillUpdate and componentDidUpdate are not executed. The component UI won't re-render.
    • this.state will still be updated within the component.
  • componentWillUpdate: Run any code before the new state is set and rendering happens
  • render: Render the updates visually to the DOM
  • componentDidUpdate: Run any code after the new state is set and the component has re-rendered

Common Pitfalls with setState()

Pitfall 1: Trying to modify state directly

The first mistake with setState() is not using setState()! =)

  • Do not modify state directly.
  • Modifying this.state directly will not trigger a Component re-render.
  • The only place where this.state should be assigned is in the component's constructor.
// WRONG: This will not re-render the component

this.state.discount = false

Solution: Use setState()

setState() will trigger a re-render of the Component.

// CORRECT: Use setState()

this.setState({
  discount: false,
})

Aside: When should something be stored in Component state?

  • If you don't use something in render(), it shouldn't be in this.state.

Pitfall 2: Trying to use setState synchronously

setState() is asynchronous. Do not call setState on one line and assume the state has already changed on the next line.

  • setState() is a request to update state, rather than an immediate command to update state.
  • setState() does not always immediately update the component.
// WRONG: setState() should not be used synchronously

// assuming this.state = { orders: 0 }
this.setState({
  orders: 1,
})

console.log(this.state.orders) // BUG! Prints out: 0

There are two solutions to this mistake:

  • Use the componentDidUpdate() lifecycle method (recommended by the React team).
  • Pass a callback as a second argument to setState().

Using componentDidUpdate or a setState callback (setState(updater, callback)) guarantees your code will execute after the state update has been applied.

Solution 1: Use the componentDidUpdate() lifecycle method

componentDidUpdate() is invoked immediately after updating occurs (but is not called on the initial render of the Component).

// CORRECT: Use the componentDidUpdate() lifecycle method

componentDidUpdate(prevProps, prevState) {
  console.log(this.state.orders); // Prints out: 1
}

Solution 2: Pass a callback function to setState()

The second parameter to setState() is an optional callback function that will be executed once setState is completed and the component is re-rendered.

// CORRECT: Pass a callback as a second argument to setState()

// assuming this.state = { orders: 0 }
this.setState(
  {
    orders: 1,
  },
  () => {
    console.log(this.state.orders) // Prints out: 1
  }
)

Pitfall 3: Trying to use a previous value of state

setState() is asynchronous. Consequently, the vales of this.state should not be used for calculating the next state.

  • this.props and this.state may be updated asynchronously.
  • this.state should not be used to calculate the next state.
// WRONG: Don't rely on this.state to calculate the next state

this.setState({
  orders: this.state.orders + this.props.increment,
})

Solution: Use the updater function form to access prevState

The first argument of the updater function, prevState, provides access to the previous state:

updater function signature:

;(prevState, props) => stateChange
// CORRECT: Use the updater function form to access prevState

this.setState((prevState, props) => ({
  orders: prevState.orders + props.increment,
}))

Pitfall 4: Trying to issue multiple setState() calls

Multiple setState() calls during the same cycle may be batched. This is specifically an issue when passing updater an object literal.

  • setState() performs a shallow merge of the updater object into the new state.
  • Subsequent setState() calls will override values from previous calls in the same cycle.
  • If the updater objects have the same keys, the value of the key, of the last object passed to Object.assign(), overrides the previous value.
// WRONG: Using an object literal with multiple setState calls during the same cycle will shallow merge the objects

// assuming this.state = { orders: 0 };
this.setState({ orders: this.state.orders + 1});
this.setState({ orders: this.state.orders + 1});
this.setState({ orders: this.state.orders + 1});

// --> OUTPUT: this.state.orders will be 1, not 3 as we would expect


// It is equivalent of an Object.assign, which performs a shallow merge
// The orders will only be incremented once

Object.assign(
  previousState,
  {orders: state.orders + 1},
  {orders: state.orders + 1},
  {orders: state.orders + 1},
  ...
)

Solution: Use the updater function form to queue state updates

By passing updater a function, the updates will be queued and later executed in the order they were called.

//CORRECT: Use the updater function form to queue state updates

// assuming this.state = { orders : 0 };
this.setState((prevState) => ({ orders: prevState.orders + 1 }))
this.setState((prevState) => ({ orders: prevState.orders + 1 }))
this.setState((prevState) => ({ orders: prevState.orders + 1 }))

// --> OUTPUT: this.state.orders will be 3