skip to Main Content

In the below code example, if you click the "Deposit money" button and then click "Lock account" quickly before the API call returns, the money still gets deposited even though the account is locked.

export default function App() {
  const [balance, setBalance] = React.useState(1000);
  const [accountLocked, setAccountLocked] = React.useState(false);

  const onDepositMoney = () => {
    fetchAPI().then(() => {
      if (!accountLocked) {
        setBalance((b) => b + 100);
      }
    });
  };

  return (
    <div>
      <div>Current balance: {balance}</div>
      <div>{accountLocked ? "Account locked" : "Account unlocked"}</div>
      <button onClick={onDepositMoney}>Deposit money</button>
      <button onClick={() => setAccountLocked(true)}>Lock account</button>
    </div>
  );
}

Code Sandbox

This is because the .then callback is defined using a stale value of accountLocked: false, and it cannot read the latest (correct) value of accountLocked: true.

What is the canonical way to ensure we have the latest state in React in async code?

  • We could combine the two state variables into one, which would fix this issue, but it doesn’t seem like a very robust solution.
  • We could also consider making a ref to track the latest value of accountLocked, but that also seems duplicative and confusing to have a ref and a state referring to the same value.

2

Answers


  1. I’ve linked this related question: Cannot retrieve current state inside async function in React.js, but I think the question here is a simpler and more general example so I’ll provide an answer here.

    The value of accountLocked is set and locked in when onDepositMoney is defined (basically the way currying works). If you want access to the current value of accountLocked you’ll need to use a mutable object whose contents can change, which is what refs are for in React.

    Here is how you can set up a ref to update with the state value of accountLocked:

    const [accountLocked, setAccountLocked] = React.useState(false);
    const accountLockedRef = React.useRef(accountLocked);
    accountLockedRef.current = accountLocked;
    

    Even though it’s only two additional lines, you can write a custom hook to make this pattern easier to use and more contained, like this:

    function useUpdatingRef(value) {
        const ref = React.useRef(value);
        ref.current = value;
        return ref;
    }
    

    Then you would use it like this:

    function App() {
        const [balance, setBalance] = React.useState(1000);
        const [accountLocked, setAccountLocked] = React.useState(false);
        const accountLockedRef = useUpdatingRef(accountLocked);
    
        const onDepositMoney = () => {
            fetchAPI().then(() => {
                if (!accountLocked.current) {
                    setBalance((b) => b + 100);
                }
            });
        };
    
        // ...
    }
    

    This approach will work with other asynchronous code as well like useEffect and setTimeout.

    One thing to note with the custom hook approach is that eslint will require you to include the ref (accountLockedRef) in the dependency array for other hooks (e.g. useEffect, useCallback). This has no effect because the value of the ref is stable but eslint can’t tell this.

    Login or Signup to reply.
  2. Just an idea, but you could also combine the states:

    const [accountState,setAccountState] = useState({locked:false,balance:1000})
    
    const onDeposit = () => {
       fetch().then(() => {
          setAccountState(state => {
             if(state.locked) return state;
             return { ...state, balance: state.balance + 100 }
          }) 
       });
    }
    
    const toggleLocked = () => setAccountState(state => ({...state,locked:!state.locked})
    
    

    Avoids all those annoying to read effects and refs

    Purists could argue those pieces of state don’t belong together. Persoanlly I’m more pragmatic than purist.

    But the simplest idea of all is: stop the user from performing any action while the API call is in progress

    Login or Signup to reply.
Please signup or login to give your own answer.
Back To Top
Search