Understanding React setState

Monday, July 27, 2020

There are a lot of concepts when it comes to React. State management is one of those which is most widely used and it is a way to hold, process and describe our UI in terms of data. The state is nothing more than a JavaScript object which we can update as per user actions, API responses or any sort of trigger.

With the introduction of React Hooks, we can use state in functional components too. However, in this blog post, we are going to focus on Class-based components and understand how to use setState and their behaviour. Moreover, some of the mentioned behaviours hold for React < 17.

class App extends Component {
  constructor(props) {
    super(props);
    this.state = {}; // Initializing state
  }
}

Updating state

The state can be directly mutated via this.state. However, it will not cause a re-render and will eventually lead to state inconsistency. You can check this behaviour using this codesandbox.

Therefore, it is recommended to use setState to perform any state updates. For the rest of the blog post, we are going to use the following example as our base.

class App extends Component {
  state = { items: [], counter: 0 };

  handleClick = () => {
    // some processing
  };

  render() {
    const { items } = this.state;
    console.log("Rendering", items);

    return (
      <div className="App">
        {items.length ? (
          <h2>Items are {JSON.stringify(items)}</h2>
        ) : (
          <Fragment>
            <p>No items found</p>
            <button onClick={this.handleClick}>Add items</button>
          </Fragment>
        )}
      </div>
    );
  }
}

Different setState function signatures

setState can be used in two different ways

  1. Passing an object as an argument
...
handleClick = () => {
  const { items } = this.state;

  this.setState({
    items: [
      ...items,
      "apple"
    ]
  });
}
...

In this case, we are passing an object to the setState function which contains a part of the overall state which we want to update. React takes this value and merges it into the final state. Like, in the above code snippet, we are appending an item apple to our items array in the state. After the merging by React, our final state would look like the following

{
  "items": ["apple"],
  "counter": 0
}

React's state merging is shallow. Hence, it has no impact on the counter variable in the state.

  1. Passing function as an argument
...
handleClick = () => {
  this.setState((state, props) => {
    const { items } = state;

    return {
      items: [
        ...items,
        "apple"
      ]
    }
  });
}
...

In this case, we are passing a function to the setState. This function will receive the previous state as the first argument, and the props at that time the update is applied as the second argument. When we talk about the previous state here then it means the most up-to-date state then (we will see how this is helpful later). It returns an object which would be merged into the final state by React.

Usecases

Now, we are going to observe the behaviour of setState invocation in different circumstances and try to understand why it is behaving in that way.

1. Multiple state updates in one scope

In our earlier code snippets, we were appending item(s) to our items array in the state. Suppose, we have a case where we want to make successive setState calls in one function scope. You can use this codesandbox to follow along.

...
handleClick = () => {
  const { items } = this.state;

  this.setState({
    items: [...items, 'apple']
  });
  this.setState({
    items: [...items, 'mango']
  });
  this.setState({
    items: [...items, 'orange']
  });
  this.setState({
    items: [...items, 'pear']
  });
};
...

Open the above-mentioned codesandbox link and check the console.

  1. We will see that the initial output would be Rendering [].

Initial Render

  1. Once we click the button then the output would be Rendering ["pear"].

After button click

Why does this happen?

Turns out React understands the execution context (important to note) and batches the setState calls as per that. No matter how many successive setState calls we make in a React event handler, it will only produce a single re-render at the end of the event and only last, as the order is preserved, reflects the state.

2. Multiple state updates in one scope using function argument signature

As we have seen above, only the last setState call is reflected. However, we can rectify this using the function argument signature of setState rather than object-based. You can use this codesandbox to follow along.

handleClick = () => {
  this.setState((state) => ({
    items: [...state.items, "apple"],
  }));
  this.setState((state) => {
    console.log(state);
    return {
      items: [...state.items, "mango"],
    };
  });
  this.setState((state) => ({
    items: [...state.items, "orange"],
  }));
  this.setState((state) => ({
    items: [...state.items, "pear"],
  }));
};

setState with callback

In this approach, React queues up the callbacks provided to different setState calls and each callback receives the most up-to-date state at that moment as their execution is synchronous. This is confirmed by the following code snippet

handleClick = () => {
  this.setState(state => ({
    items: [...state.items, 'apple']
  }));
  this.setState(state => {
    console.log(state); // { "items": ["apple"] }
    return {
      items: [...state.items, 'mango']
    };
  });
  ...
};

As you can see the state received by the second setState callback already has the item apple in the items array. Since we are still in the React event handler, all the setState calls will cause a single re-render.

3. Multiple state updates in lifecycle methods

As we have seen above React batches setState calls. However, in current versions (< 17), this is only true inside event handlers and lifecycle methods. You can use this codesandbox to follow along.

...
componentDidMount() {
  const data = ["apple", "orange", "mango", "pear"];
  this.setState(state => {
    return {
      items: { ...state.items, 1: { id: 1, value: data[0] } }
    };
  });
  this.setState(state => {
    return {
      items: { ...state.items, 2: { id: 2, value: data[1] } }
    };
  });
  this.setState(state => {
    return {
      items: { ...state.items, 3: { id: 3, value: data[2] } }
    };
  });
  this.setState(state => {
    return {
      items: { ...state.items, 4: { id: 4, value: data[3] } }
    };
  });
}
render() {
  const { items } = this.state;
  console.log("Rendering...", items);
...

setState in lifecycle methods

There is only one re-render despite multiple state updates. It only happens in React controlled synthetic event handlers.

4. Multiple state updates in AJAX, promises, and setTimeouts

React understands the execution context. No matter where your AJAX, promise or setTimeout is happening as in inside lifecycle methods or event handlers, React will not batch leading to multiple re-renders. You can use this codesandbox to follow along.

...
handleClick = () => {
  this.setState(state => ({
    items: [...state.items, "single"]
  }));
  this.setState(state => ({
    items: [...state.items, "render"]
  }));
  return fakeAPI().then(() => {
    this.setState(state => ({
      items: [...state.items, "apple"]
    }));
    this.setState(state => ({
      items: [...state.items, "mango"]
    }));
    this.setState(state => ({
      items: [...state.items, "orange"]
    }));
    this.setState(state => ({
      items: [...state.items, "pear"]
    }));
  });
};
...

setState outside React controlled methods

As we can see the first two setState calls causes a single re-render. However, calls inside the promise lead to multiple intermediate re-renders.

As per the React team, there might be more uniform behaviour from React version 17 where batching of setState will happen regardless of the execution context.