4

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

hgb123
  • 13,869
  • 3
  • 20
  • 38
Ziko
  • 919
  • 2
  • 10
  • 22
  • Does this answer your question? [When to use functional setState](https://stackoverflow.com/questions/48209452/when-to-use-functional-setstate) – Emile Bergeron Aug 25 '20 at 03:33
  • While it may not be super clear, you can add condition inside the updater function to check if it's still relevant to update with the organization data you've just received or if it's too late. e.g. `setSelectedOrganization(currentOrg => isStillRelevant ? newOrg : currentOrg)` – Emile Bergeron Aug 25 '20 at 03:35
  • @EmileBergeron Not really. It won't help in my case, I'm aware of the functional setState – Ziko Aug 25 '20 at 03:43
  • @EmileBergeron Regarding your second comment. I don't want to block the organization selection itself, I want the organization selection to work but I would block the bad state update at the right side due to API calls response time. It's easier for me to block any organization selection until we get a response but I don't want to do that, well at least unless I don't have any choice.. – Ziko Aug 25 '20 at 03:44
  • Pass `selectedOrganization` as argument to `refreshOrganizationsList()` and try? – sachinkondana Aug 25 '20 at 04:16
  • @sachinkondana I tried that. Didn't work.. Also the funny thing is that the selectedOrganizaiton here was even outdated more! Also It contained the old value still. For example, refreshOrganizationsList(1), then refreshOrganizationList(2) Both promises when they return, the argument will be the value from where it was called so we didn't solve anything.. – Ziko Aug 25 '20 at 04:23
  • The results of the console.log 134 (the most updated *1), from function call 42, from global var 42, from outside scope 42, from local var 42, from request 42, from func 42, the "from func call" one is the argument passed to refreshOrganizationsList(selectedOrganization.id) @sachinkondana – Ziko Aug 25 '20 at 04:26
  • Please update the question with an [mcve]. It's missing how the state is set in the promise and how/what is selected and where it is set and called. – Emile Bergeron Aug 25 '20 at 04:33
  • @EmileBergeron You can see the state is set within handleSelectedOrganization() which will be called from a third component when we select a new organization from the menu. No need to include that component but I left a comment saying how it gets called. User selects an item from the menu -> handleSelectedOrganization() * changes the state * -> Child component useEffect will detect that the selectedOrganization changed and will call refreshOrganizationsList. – Ziko Aug 25 '20 at 04:36
  • It's missing the state of the `UsersView` and how the promise result is saved in the state. The solution might be closer to that part of the code. – Emile Bergeron Aug 25 '20 at 04:40
  • @Ziko, you should consider canceling your requests once you select a different org. So, you should embed that it in the on-switch-handler something like [how-to-cancel-abort-ajax-request-in-axios](https://stackoverflow.com/questions/38329209/how-to-cancel-abort-ajax-request-in-axios). – trk Aug 25 '20 at 05:18

1 Answers1

1

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).

Emile Bergeron
  • 17,074
  • 5
  • 83
  • 129
  • 1
    You are a life saver! Thank you so much, works as expected :). I would need to read and understand more what you did here, this would help me in many other cases going forward – Ziko Aug 25 '20 at 22:13