May 17, 2024

The Pitfalls of useState with Asynchronous Functions in React

by Marco Tisi

In React development, managing state efficiently and correctly is crucial for building responsive and reliable applications. While useState is the most commonly used hook for state management in functional components, it can present challenges when used in conjunction with asynchronous functions.

Here, we'll explore these challenges, demonstrate them with a Counter component, and discuss better approaches, including using useReducer.

The Subtle Challenges of useState

The useState hook provides a way to add state to functional components. However, developers can face unexpected behaviors when interacting with asynchronous code, mainly due to how closures work in JavaScript.

A closure in JavaScript captures the variables from its scope at the time it is created, not when it is executed. This can lead to bugs, especially when the state update depends on the previous state.

Here's a simple example of a Counter component that tries to increment a count after a delay:

const Counter = () => {
  const [count, setCount] = React.useState(0);

  const incrementCountAsync = () => {
    return new Promise((resolve) => setTimeout(resolve, 1000)).then(() =>
      setCount(count + 1)
    );
  };

  return (
    <div>
      <h1>Count: {count}</h1>
      <button onClick={incrementCountAsync}>Increment</button>
    </div>
  );
};

What happens if you click the "Increment" button multiple times before the delay is over? Try it out in the interactive example below:

The count will not increment as expected. This is because the setCount function captures the count value at the time the component is rendered, not when the asynchronous function is executed.

Adopting Functional Updates with useState

To address these issues, React provides the option of functional updates within useState. By passing a function to the setter returned by useState, we ensure that the state update always applies to the most recent state:

const incrementCountAsync = () => {
  return new Promise((resolve) => setTimeout(resolve, 1000)).then(() =>
    setCount((prevCount) => prevCount + 1)
  );
};

This ensures that setCount always uses the most current value of count at the time the update is applied, regardless of when the asynchronous operation was initiated.

Try the updated example below:

Leveraging useReducer for Complex State Logic

While functional updates can solve many issues, they can become cumbersome when dealing with complex state logic. In such cases, useReducer can be a better alternative.

This hook simplifies state management by treating updates as actions that describe "what happened" rather than "what the next state should be." This approach is especially useful when the next state depends on the previous state or when multiple state values are interdependent.

Here's how you can rewrite the Counter component using useReducer:

const Counter = () => {
  const [state, dispatch] = React.useReducer(reducer, { count: 0 });

  const incrementCountAsync = () => {
    return new Promise((resolve) => setTimeout(resolve, 1000)).then(() =>
      dispatch({ type: 'increment' })
    );
  };

  return (
    <div>
      <h1>Count: {state.count}</h1>
      <button onClick={incrementCountAsync}>Increment</button>
    </div>
  );
};

const reducer = (state, action) => {
  switch (action.type) {
    case 'increment':
      return { count: state.count + 1 };
    default:
      return state;
  }
};

In this setup, the dispatch function from useReducer does not suffer from the same closure issues as useState because it always applies actions based on the current state when the action is processed, not when it is dispatched.

As usual, try the updated example below:

Conclusion

Understanding how state updates occur in React and how they interact with JavaScript's asynchronous nature and closures is key to building robust applications. While useState is suitable for many scenarios, using functional updates or useReducer can provide more reliable behavior in asynchronous environments, enhancing both the stability and predictability of your application.

Interested in tackling real-world challenges like these? Check out the open positions at Close and join a team that values innovation and efficiency.