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?

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 being false, but the data 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.