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}
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.
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}
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}
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}
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: