Ziko
Ziko

Reputation: 971

State is always outdated inside the promise resolve method

So I'm running into a weird issue with my SPA regarding states.

I have a left side menu with items, let's say organization and when I click on any of them, the right hand panel changes with the appropriate list of users within the selected organization.

For example: I have organization 1 and organization 2 in the list, and if I click on organization 1, I send a request to middleware to retrieve the list of users within that organization and if I select organization 2, I do that same thing.

So I have a higher component Organization.js that has the following code:-

// Organization.js

const [selectedOrganization, setSelectedOrganization] = useState(null);

// This method will be called when we select an organization from the menu

const handleSelectedOrganization = organization => {
if (!selectedOrganization || selectedOrganization.id !== organization.id) {
  setSelectedOrganization(organization);
 }
};

return (
     <UsersView selectedOrganization={selectedOrganization} />
);

UsersView.js

const UsersView = ({ selectedOrganization = {} }) => {

  const [selectedOrganizationUsers, setSelectedOrganizationUsers] = useState(
[]);
let globalOrganization = selectedOrganization?.id; // global var

const refreshOrganizationsList = () => {
const localOrganization = selectedOrganization.id; // local var
Promise.all([ // bunch of requests here]).then(data => {
  console.log('from global var', globalOrganization); // outdated
  console.log('from local var', localOrganization); // outdated
  console.log('from prop', selectedOrganization.id); // outdated
  setSelectedOrganizationUsers(data.result); // data.result is an array of objects
});
};


// Will be called when the selectedOrganization prop changes, basically when I select
//a new organization from the menu, the higher component will
// change state that will reflect here since the prop will change.
useEffect(() => {
if (selectedOrganization) {
  globalOrganization = selectedOrganization.id;
  refreshOrganizationsList();
}
}, [selectedOrganization]);

console.log(selectedOrganization?.id); // Always updated *1

return (
{selectedOrganizationUsers?.length ? (
        <>
          <div className="headers">
          ....
)

Now the problem is, some API calls take too long to respond, and in a particular scenario when I switch between orgs fast, we would get some pending API calls and when the response comes, the states are messed up.

For example: If I select from the menu Organization 1, we send 3 requests to middleware that would remain pending let's say for 10 seconds.

If after 5 seconds, I choose Organization 2 from the menu where its API requests would be instant, the right hand panel will be updated with the Organization 2 data but then after 5 seconds, when Organization 1 requests get the responses, the list gets updated with Organization 1 data which is what I try to prevent since now we have selected organization 2.

The reason why I have console.logs in the .then() is because I try to block updating the states when the current selectedOrganization !== the organization.id in the response.

But unfortunately, the console.logs in the above scenario would should me the organization id = 1 and not 2, even if I have selected organization 2 already.

For example:

I select Organization 1, then I selected Organization 2

once I select Organization 2, the outside *1 console.log would log 2 immediately in my browser.

But when I get the API responses of 1, the console.logs inside the .then() gives me 1 not 2, I expect them to give me 2 so that I can make an if (request.organization_id !== selectedOrganization.id) -> don't update the states

Long story short, it seems that when the API call returns with a result, the organization.id within the .then() is always the one was had when we fired the request itself and not the most updated part. As if it's no longer tied with the recent value within the props that comes from the state of the higher component

Upvotes: 5

Views: 742

Answers (1)

Emile Bergeron
Emile Bergeron

Reputation: 17430

Use the functional updater function to compare to the latest state

So a possible solution is exactly related to what I linked in the comment, though it might not be that obvious at first.

First, you'll need to slightly change the state structure. If this gets too complicated over time, you might want to take a look at useReducer, the context API, or a full-fledged state management library like Redux or similar.

Then, use the functional updater function to compare to the latest state values, which might have changed if the selected organization has since changed.

const UsersView = ({ selectedOrganization }) => {
  // Slightly change the state structure.
  const [{ users }, setState] = useState({
    currentOrgId: selectedOrganization?.id,
    users: [],
  });

  const refreshOrganizationsList = (orgId) => {
    // Set the currentOrgId in the state so we remember which org was the last fetch for.
    setState((state) => ({ ...state, currentOrgId: orgId }));

    Promise.all([
      /* bunch of requests here */
    ]).then((data) => {
      setSelectedOrganizationUsers((state) => {
        // Is the current org still the one we started the fetch for?
        if (state.currentOrgId !== orgId) {
          // Precondition failed? Early return without updating the state.
          return state;
        }

        // Happy path, update the state.
        return {
          ...state,
          users: data.result,
        };
      });
    });
  };

  useEffect(() => {
    if (selectedOrganization) {
      // instead of a component scoped variable, just pass the id as a param.
      refreshOrganizationsList(selectedOrganization.id);
    }
  }, [selectedOrganization]);

  return (/* JSX here */);
};

There's no longer any needs for local variables. In fact, local variables get captured by the closure and even if the component is rendered again, the values won't change inside of the old refreshOrganizationsList function reference (which gets recreated each render cycle).

Upvotes: 1

Related Questions