Why react doesn't update state in "then" promise?

Viewed 762

I have a table with Delete button in each row. When you delete the row, the Client will send request do the API asynchronously. Which means, that if you delete multiple rows quickly, you don't have to wait for one request to finish, to send another Delete request. When you click Delete button and request was send, the button will be disabled (before the response comes back and the row will disappear).

This is the function that handles delete in the Container:

  const [deletingTeams, setDeletingTeams] = useState([]);

  const handleDelete = (teamId) => {
    setDeletingTeams([...deletingTeams, teamId]);

    deleteTeam(teamId).then(() => {    
      console.log(deletingTeams); <---- This prints EMPTY array. Why????   
      let newState = deletingTeams.filter((id) => id !== teamId);
      setDeletingTeams(newState);
    });
  };

Very simple. deletingTeams is an array that holds ids of teams that are being deleted at the moment (the response didn't comeback from that API, that deletion was successful).

In line 3 (setDeletingTeams([...deletingTeams, teamId]);) I'm adding new teamId to the array, and it works. New array is being passed to the List component and Delete button is indeed disabled. BUT...

...when response comes back, in then promise, I want to remove that teamId from the array. The problem is, that deletingTeams array is already empty (before the filter). Why??

Thanks for the explanation,

2 Answers

State updates are async - you are not guaranteed to see latest value of the updated state straight away.

Use the callback approach to filter and set the state.

const [deletingTeams, setDeletingTeams] = useState([]);

const handleDelete = (teamId) => {
  setDeletingTeams([...deletingTeams, teamId]);

  deleteTeam(teamId).then(() => {
    // console.log(deletingTeams); //<---- state updates are async - you will see the latest value of the state in the next re-render
    // let newState = deletingTeams.filter((id) => id !== teamId); //<---- remove this
    setDeletingTeams((prev) => prev.filter((id) => id !== teamId)); //<--- use call back approach to set the state
  });
};

In short, the reason this is happening is because deletingTeams gets its value before your asynchronous call. The reasons why are subtle!

What's going on is, when you use detetingTeams in the then function for deleteTeam, the callback function you pass in is using a JavaScript feature called function closures. The variable deletingTeams is used in your then function - but it's not defined by that function! So it can't have local function scope. Where exactly is it coming from, then?

The answer is nuanced. The variable is defined in your first line: const [deletingTeams, setDeletingTeams] = useState([]);. At this point, the variable is assigned a value based on your components' current state - the teams you currently have before deleting. Then, you reference it in your function. At this time, the variable deletingTeams becomes added to the then functions' scope. It is a reference to the same variable outside the function!

Ok, so the reason that deletingTeams is an empty array is because it's an empty array when you create the then function, right? Not quite. Any changes to deletingTeams are reflected when you execute the then function. But in your example, deletingTeams never changes its value. Your component's state changes when you call setDeletingTeams, but you only look up the state once - at the beginning of the block of code. There's no magic that will change deletingTeams' value; for that, you have to call useState again.

deletingTeams is given a fresh value each time your component rerenders. But unfortunately, the then function's closure scope is only determined once. (Each time you rerender, you actually generate a new then function, scoped to that render's version of the deletingTeams variable!) The component may have rerendered, but the then block is still called with the same stale value.

So, altogether, here's what's going on:

  1. deletingTeams is assigned a value in your first line: const [deletingTeams, setDeletingTeams] = useState([]);
  2. You update the state with setDeletingTeams, but useState is only called once, so deletingTeams is never updates to match new values.
  3. deletingTeams gets a new value on each new render of your component, but by then, your then function is unfortunately already bound to a specific, past deletingTeams variable that never updates.
  4. So when your then block executes, deletingTeams has a stale value.

So what is the correct approach, then? We can read the state in the then function to get the freshest value of deletingTeams. It's simply this.state.deletingTeams. Here's more information: https://reactjs.org/docs/hooks-state.html#reading-state

(Because you're using arrow functions, the then function automatically uses the same this as the component, so everything should work.)

So this should do the trick:

  const [deletingTeams, setDeletingTeams] = useState([]);

  const handleDelete = (teamId) => {
    setDeletingTeams([...deletingTeams, teamId]);

    deleteTeam(teamId).then(() => {    
      let newState = this.state.deletingTeams.filter((id) => id !== teamId);
      setDeletingTeams(newState);
    });
  };

(Be sure to use this.state.deletingTeams instead of calling useState again in this kind of situation. Calling useState inside of nested functions or callbacks can lead to bugs: https://reactjs.org/docs/hooks-rules.html#only-call-hooks-at-the-top-level)

Related