React not batching your setState updates? Here's what you can do about it
Say you're calling two state update functions, one after the other
const [data, setData] = useState(null);
const [isLoading, setLoading] = useState(false);
// on receiving the API response
setIsLoading(false);
setData(data);
Depending on whether your code runs as part of an event handler or inside a regular function, this will trigger two rerenders or just one, based on whether React batches the setState
calls or not.
Until React 18, we only batched updates during the React event handlers. Updates inside of promises, setTimeout, native event handlers, or any other event were not batched in React by default.
https://github.com/reactwg/react-18/discussions/21
This behaviour will change in React 18, but until then, what can you do to prevent multiple unnecessary rerenders and potentially invalid states?
Approach 1: Group related state variables in one state object
It's a good practice to analyse the things you keep in the state and see whether they change together or not.
In this case, the data
and isLoading
variables do change together - we always want the loading to stop when the data is received
Thus, it makes sense to create just one state
for both:
const [response, setResponse] = useState({ data: null, isLoading: false })
// on receiving the API response
setResponse({ ...response, data, isLoading: false })
While this works for very simple cases, in practice, it can become tedious and error prone, since you need to manually merge the previous state and the new changed values every time.
This is way a better approach is to switch to using the useReducer
hook.
Approach 2: Refactor your code to use the useReducer hook
Let's see how the same example would look using useReducer
:
const reducer = (state, action) => {
switch(action.type) {
case 'API_SUCCESS':
return {
...state,
data: action.payload.data,
isLoading: false
}
default:
return state;
}
}
const initialState = { data: null, isLoading: false}
const [state, dispatch] = useReducer(reducer, initialState)
// on receiving the API response
dispatch({ type: 'API_SUCCESS', payload: { data }})
While this does increase the complexity of the code a little, in the long term it might be a better choice:
- it makes it easy to extend the component with new functionality: for example, handling the error states can be added with a few lines changes in the
reducer
- it makes the component state more predictable: all state transitions are defined in one single place, making it easier to reason about how the state changes
- it prevents invalid state values (e.g.
isLoading
beingfalse
, but thedata
not being updated yet) and unnecessary rerenders, as the state is only updated once no matter how many values changed
I hope you found this useful!
If you'd like to read more on the topic, make sure to take a look how batching will work in React 18.