Why React Re‑Renders Can Be Disasterous and How to Prevent Excessive Updates
This article explains the mechanics behind React re‑rendering, how state and props changes trigger renders, common pitfalls that cause multiple renders in class and function components, and practical strategies—including batching, state merging, useReducer, and refs—to minimize unnecessary updates.
Origin
In React, re‑rendering means the render function is executed again in a function component, similar to the build function in Flutter. When a component's state or props change, React re‑renders; this simple rule can cause disastrous re‑renders if not careful.
Class Components
Why start with class components? They are common interview topics.
React setState is synchronous in some cases and asynchronous in others.
How to obtain the latest state after setState?
What is the output of the following code and how does the page change?
test = () => {
// s1 = 1
const { s1 } = this.state;
this.setState({ s1: s1 + 1 });
this.setState({ s1: s1 + 1 });
this.setState({ s1: s1 + 1 });
console.log(s1)
};
render() {
return (
<div>
<button onClick={this.test}>按钮</button>
<div>{this.state.s1}</div>
</div>
);
}Familiarity with React's transaction mechanism helps answer such interview questions.
React Synthetic Events
Events triggered by React components bubble to the document (in React v17 the document node is the mount point). React collects callbacks along the propagation path and dispatches them.
If native event propagation is disabled, React synthetic events cannot be triggered because the native event never bubbles.
Even if a synthetic event is captured, the native event still fires first.
reactEventCallback = () => {
const { s1, s2, s3 } = this.state;
this.setState({ s1: s1 + 1 });
this.setState({ s2: s2 + 1 });
this.setState({ s3: s3 + 1 });
console.log('after setState s1:', this.state.s1);
};Timer Callback setState
setState inside a timer callback is synchronous, so the latest state can be read immediately after the call.
timerCallback = () => {
setTimeout(() => {
const { s1, s2, s3 } = this.state;
this.setState({ s1: s1 + 1 });
console.log('after setState s1:', this.state.s1);
this.setState({ s2: s2 + 1 });
this.setState({ s3: s3 + 1 });
});
};Async Function setState
setState inside a Promise callback behaves the same way as in a timer.
asyncCallback = () => {
Promise.resolve().then(() => {
const { s1, s2, s3 } = this.state;
this.setState({ s1: s1 + 1 });
console.log('after setState s1:', this.state.s1);
this.setState({ s2: s2 + 1 });
this.setState({ s3: s3 + 1 });
});
};Native Event Trigger
Native events are not affected by React's transaction mechanism, so setState remains synchronous.
componentDidMount() {
const btn1 = document.getElementById('native-event');
btn1?.addEventListener('click', this.nativeCallback);
}
nativeCallback = () => {
const { s1, s2, s3 } = this.state;
this.setState({ s1: s1 + 1 });
console.log('after setState s1:', this.state.s1);
this.setState({ s2: s2 + 1 });
this.setState({ s3: s3 + 1 });
};setState Modifying Non‑Rendering Properties
Calling setState always triggers a re‑render, even if the updated state is not used in the UI. To modify non‑rendering data, assign directly to this or use refs.
// s1 s2 s3 are rendered, s4 is non‑rendered
state = { s1: 1, s2: 1, s3: 1, s4: 1 };
changeNotUsedState = () => {
const { s4 } = this.state;
this.setState({ s4: s4 + 1 }); // page re‑renders
this.state.s4 = 2; // no re‑render
this.s5 = 2; // no re‑render
};Will a Plain setState Call Re‑Render?
Both calling setState without parameters and calling setState with a new state identical to the old one still cause a re‑render.
sameState = () => {
const { s1 } = this.state;
this.setState({ s1 }); // re‑renders
};
noParams = () => {
this.setState({}); // re‑renders
};Multiple Render Problem
Repeated setState calls in async callbacks cause multiple renders. To avoid this, merge updates into a single object and call setState once.
asyncCallbackMerge = () => {
Promise.resolve().then(() => {
const { s1, s2, s3 } = this.state;
this.setState({ s1: s1 + 1, s2: s2 + 1, s3: s3 + 1 });
console.log('after setState s1:', this.state.s1);
});
};Test Code
import React from 'react';
interface State { s1: number; s2: number; s3: number; s4: number; }
export default class TestClass extends React.Component<any, State> {
renderTime: number;
constructor(props: any) {
super(props);
this.renderTime = 0;
this.state = { s1: 1, s2: 1, s3: 1, s4: 1 };
}
componentDidMount() {
const btn1 = document.getElementById('native-event');
const btn2 = document.getElementById('native-event-async');
btn1?.addEventListener('click', this.nativeCallback);
btn2?.addEventListener('click', this.nativeCallbackMerge);
}
// ... (methods omitted for brevity, same as source) ...
render() {
console.log('renderTime', ++this.renderTime);
const { s1, s2, s3 } = this.state;
return (
<div className="test">
<button onClick={this.reactEventCallback}>React Event</button>
<button onClick={this.timerCallback}>Timer Callback</button>
<button onClick={this.asyncCallback}>Async Callback</button>
<button id="native-event">Native Event</button>
<button onClick={this.timerCallbackMerge}>Timer Callback Merge</button>
<button onClick={this.asyncCallbackMerge}>Async Callback Merge</button>
<button id="native-event-async">Native Event Merge</button>
<button onClick={this.changeNotUsedState}>Change Not Used State</button>
<button onClick={this.sameState}>React Event Set Same State</button>
<button onClick={this.withoutParams}>React Event SetState Without Params</button>
<div>S1: {s1} S2: {s2} S3: {s3}</div>
</div>
);
}
}Function Components
Function components re‑render under the same conditions as class components: when props or state change. Because hook state is not a single object, the problem can be worse.
React Synthetic Events (Hook)
const reactEventCallback = () => {
setS1(i => i + 1);
setS2(i => i + 1);
setS3(i => i + 1);
// page renders once, S1 S2 S3 become 2
};Timer Callback (Hook)
const timerCallback = () => {
setTimeout(() => {
setS1(i => i + 1);
setS2(i => i + 1);
setS3(i => i + 1);
// page renders three times, S1 S2 S3 become 2
});
};Async Callback (Hook)
const asyncCallback = () => {
Promise.resolve().then(() => {
setS1(i => i + 1);
setS2(i => i + 1);
setS3(i => i + 1);
// page renders three times, S1 S2 S3 become 2
});
};Native Event (Hook)
useEffect(() => {
const handler = () => {
setS1(i => i + 1);
setS2(i => i + 1);
setS3(i => i + 1);
};
containerRef.current?.addEventListener('click', handler);
return () => containerRef.current?.removeEventListener('click', handler);
}, []);Updating Unused State
const [s4, setS4] = useState(1);
const unuseState = () => {
setS4(i => i + 1); // s4 becomes 2, page renders once, s4 not shown in UI
};Multiple Render Cases
In React Hooks, setting the same state value may still cause a render, unlike class components where identical state updates are skipped.
// React Hook
const sameState = () => {
setS1(i => i);
setS2(i => i);
setS3(i => i);
console.log(renderTimeRef.current);
// page does not re‑render
};
// Class component
sameState = () => {
const { s1, s2, s3 } = this.state;
this.setState({ s1 });
this.setState({ s2 });
this.setState({ s3 });
console.log('after setState s1:', this.state.s1);
// page re‑renders
};Avoiding Multiple Renders in Hooks
Because hook state is not an object, it does not merge automatically. Solutions include merging all state into a single object, using useReducer, or storing values in refs.
Merging All State into One Object
const [state, setState] = useState({ s1: 1, s2: 1, s3: 1 });
setState(prev => {
setTimeout(() => {
const { s1, s2, s3 } = prev;
return { ...prev, s1: s1 + 1, s2: s2 + 1, s3: s3 + 1 };
});
});Using useReducer
const initialState = { s1: 1, s2: 1, s3: 1 };
function reducer(state, action) {
switch (action.type) {
case 'update':
return { s1: state.s1 + 1, s2: state.s2 + 1, s3: state.s3 + 1 };
default:
return state;
}
}
const [reducerState, dispatch] = useReducer(reducer, initialState);
const reducerDispatch = () => {
setTimeout(() => {
dispatch({ type: 'update' });
});
};Storing Non‑Rendering Data in Refs
const [s4, setS4] = useState(1);
const [, update] = useReducer(c => c + 1, 0);
const state1Ref = useRef(1);
const state2Ref = useRef(1);
const unRefSetState = () => {
state1Ref.current += 1;
state2Ref.current += 1;
setS4(i => i + 1);
};
const unRefReducer = () => {
state1Ref.current += 1;
state2Ref.current += 1;
update();
};Custom Hook
A custom hook that fetches data updates an internal id state and the fetched data. Even if only data is returned, the change of id still triggers a component re‑render.
// simple custom hook
const useData = () => {
const [id, setId] = useState(0);
const [data, setData] = useState(null);
useEffect(() => {
fetch('request‑url')
.then(r => r.json())
.then(r => {
setId(i => i + 1);
setData(r);
});
}, []);
return data;
};
const data = useData();Summary
All the scenarios described behave the same in React Hooks as in class components, except that identical state updates in Hooks may still cause a render. React 18 introduces automatic batching, which merges multiple setState calls even in async callbacks, timers, or native events, reducing unnecessary renders.
Reference
Automatic batching for fewer renders in React 18: https://github.com/reactwg/react-18/discussions/21
How this landed with the community
Was this worth your time?
0 Comments
Thoughtful readers leave field notes, pushback, and hard-won operational detail here.
