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?
accountLocked
, but that also seems duplicative and confusing to have a ref and a state referring to the same value.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.