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>
);
}
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
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 whenonDepositMoney
is defined (basically the way currying works). If you want access to the current value ofaccountLocked
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
: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:
Then you would use it like this:
This approach will work with other asynchronous code as well like
useEffect
andsetTimeout
.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.Just an idea, but you could also combine the states:
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