cover-image

React: Avoid Unnecessary Renders with Batch State Updates

#react
#javascript
#typescript
#web

Andrew Vo-Nguyen

February 10, 2022

6 min read

Have you ever wondered why your component is re-rendering multiple time consecutively when you only expect it to re-render once? Perhaps you update some local state and somehow multiple renders occur as a result? Lets take this hooks based example:

1import { useState, useRef } from "react";
2
3export default function () {
4  const renderCount = useRef(0);
5  const [_dateA, setDateA] = useState();
6  const [_dateB, setDateB] = useState();
7
8  async function asyncHandler() {
9    await new Promise((resolve) => setTimeout(resolve, 100));
10    setDateA(Date.now());
11    setDateB(Date.now());
12  }
13
14  function syncHandler() {
15    setDateA(Date.now());
16    setDateB(Date.now());
17  }
18
19  console.log(`render count: ${++renderCount.current}`);
20
21  return (
22    <div>
23      <p>Problem</p>
24      <div style={{ display: "flex", gap: "4px" }}>
25        <button onClick={syncHandler}>Update state synchronously</button>
26        <button onClick={asyncHandler}>Update state asynchronously</button>
27      </div>
28    </div>
29  );
30}
image-ce2269b483ba50dfc5247c5225435a1e5bab055a-640x480-gif

Under synchronous operations or with React event handlers, React will batch all setState calls into a single render. However React does not batch setState calls when performing an asynchronous operations. There are three solutions that I propose to clean up the unnecessary re-renders.

Solution A: Combine State

This solution couples dateA and dateB into a single object. This means that anytime you need to update dateA or dateB, you will be updating the one object. The downside to this approach is that dateA and dateB will always be tightly coupled and you will always need to know previous state to merge in with the new state when updating just one of the two dates.

1import { useState, useRef } from "react";
2
3export default function () {
4  const renderCount = useRef(0);
5  const [, setDates] = useState({ dateA: undefined, dateB: undefined });
6
7  async function asyncHandler() {
8    await new Promise((resolve) => setTimeout(resolve, 100));
9    setDates({ dateA: Date.now(), dateB: Date.now() });
10  }
11
12  async function updateDateAOnly() {
13    await new Promise((resolve) => setTimeout(resolve, 100));
14    setDates((prevState) => ({ ...prevState, dateA: Date.now() }));
15  }
16
17  async function updateDateBOnly() {
18    await new Promise((resolve) => setTimeout(resolve, 100));
19    setDates((prevState) => ({ ...prevState, dateB: Date.now() }));
20  }
21
22  console.log(`Solution A render count: ${++renderCount.current}`);
23
24  return (
25    <div>
26      <p>Solution A</p>
27      <div style={{ display: "flex", gap: "4px" }}>
28        <button onClick={asyncHandler}>Update state asynchronously</button>
29      </div>
30    </div>
31  );
32}

Solution B: Batch Update API

This solution involves using React’s unstable_batchedUpdates API. Although labelled unstable, I have not experienced any issues with this method. React does plan on implementing this behaviour as the default behaviour in the near future. This is probably the cleanest solution that decouples dateA and dateB.

1import { useState, useRef } from "react";
2import { unstable_batchedUpdates } from "react-dom";
3
4export default function () {
5  const renderCount = useRef(0);
6  const [_dateA, setDateA] = useState();
7  const [_dateB, setDateB] = useState();
8
9  async function asyncHandler() {
10    await new Promise((resolve) => setTimeout(resolve, 100));
11    unstable_batchedUpdates(() => {
12      setDateA(Date.now());
13      setDateB(Date.now());
14    });
15  }
16
17  async function updateDateAOnly() {
18    await new Promise((resolve) => setTimeout(resolve, 100));
19    setDateA(Date.now());
20  }
21
22  async function updateDateBOnly() {
23    await new Promise((resolve) => setTimeout(resolve, 100));
24    setDateB(Date.now());
25  }
26
27  console.log(`Solution B render count: ${++renderCount.current}`);
28
29  return (
30    <div>
31      <p>Solution B</p>
32      <div style={{ display: "flex", gap: "4px" }}>
33        <button onClick={asyncHandler}>Update state asynchronously</button>
34      </div>
35    </div>
36  );
37}

Solution C: useReducer Hook

useReducer implements a redux state pattern into your local state. This allows you to perform multiple state updates in a single dispatch call. The redux state pattern is highly reliable but does require a lot of boilerplate code to write a reducer and your dispatch calls. If you’re using TypeScript, this adds a lot of bloat to a single component.

1import { useReducer, useRef } from "react";
2
3const initialState = { dateA: undefined, dateB: undefined };
4
5function reducer(state, action) {
6  switch (action.type) {
7    case "UPDATE_DATE_A":
8      return { ...state, dateA: action.data.date };
9    case "UPDATE_DATE_B":
10      return { ...state, dateB: action.data.date };
11    case "UPDATE_BOTH_DATES":
12      return { dateA: action.data.dateA, dateB: action.data.dateB };
13    default:
14      throw new Error();
15  }
16}
17
18export default function () {
19  const renderCount = useRef(0);
20  const [_state, dispatch] = useReducer(reducer, initialState);
21
22  async function asyncHandler() {
23    await new Promise((resolve) => setTimeout(resolve, 100));
24    dispatch({
25      type: "UPDATE_BOTH_DATES",
26      data: { dateA: Date.now(), dateB: Date.now() }
27    });
28  }
29
30  async function updateDateAOnly() {
31    await new Promise((resolve) => setTimeout(resolve, 100));
32    dispatch({
33      type: "UPDATE_DATE_A",
34      data: { date: Date.now() }
35    });
36  }
37
38  async function updateDateBOnly() {
39    await new Promise((resolve) => setTimeout(resolve, 100));
40    dispatch({
41      type: "UPDATE_DATE_B",
42      data: { date: Date.now() }
43    });
44  }
45
46  console.log(`Solution C render count: ${++renderCount.current}`);
47
48  return (
49    <div>
50      <p>Solution C</p>
51      <div style={{ display: "flex", gap: "4px" }}>
52        <button onClick={asyncHandler}>Update state asynchronously</button>
53      </div>
54    </div>
55  );
56}

Bonus: Batching Redux Dispatch Calls

Another cause of excessive renders could come from your global state. Redux uses the unstable_batchedUpdates API under the hood to batch their dispatch actions into a single call. This batch behaviour can be confirmed using the Redux Dev Tools.

1import { batch, useDispatch, useSelector } from 'react-redux';
2import { setDateA, setDateB } from './actions/date';
3
4export default function () {
5	const { dateA, dateB } = useSelector((state) => state.dateState)
6			
7  async function asyncHandler() {
8	  await new Promise((resolve) => setTimeout(resolve, 100));
9	  batch(() => {
10      dispatch(setDateA(Date.now()));
11      dispatch(setDateb(Date.now()));
12    });
13	}
14
15  return (
16    <div>
17      <p>Bonus Solution</p>
18      <div style={{ display: "flex", gap: "4px" }}>
19        <button onClick={asyncHandler}>Update state asynchronously</button>
20      </div>
21    </div>
22  );
23}

Full demo on all 3 solutions are available on Code Sandbox:

React: Avoid Unnecessary Renders