2

So I have run into an issue where React is not batching together multiple setState() calls inside of an async function (React usually does this automatically outside of an async function). This means that when my code is run, multiple consecutive setState() calls conflict with eachother, the first setState() causes the component to update and the second setState() happens before the component has had enough time to remount and I get the error "Can't perform a React state update on an unmounted component". I did some research and found out that this is expected behaviour but I've found nothing about how to fix the issue.

Here's some example code (doesn't run) just to visualise what I'm trying to say. In this case the lines setData(response.data); and setLoading(false); conflict and cause the error.

I need some way to make the two calls atomic.

import React, { useState } from "react";
import Button from "@material-ui/core/Button";

const Demo = () => {
  const [loading, setLoading] = useState(false);
  const [data, setData] = useState({});

  const fetchData = async () => {
    setLoading(true);
    const response = await callAPI();
    if (response.status === 200) {
      setData(response.data);
    }
    setLoading(false);
  };

  return (
    <div>
      <Button disabled={loading} onClick={() => fetchData()}>
        Fetch Data
      </Button>
      <p>{JSON.stringify(data)}</p>
    </div>
  );
};

export default Demo;
maxout
  • 307
  • 1
  • 5
  • 8
  • you may find your answer here: https://stackoverflow.com/questions/42018342/is-there-a-synchronous-alternative-of-setstate-in-reactjs – Ignacio Jan 19 '20 at 21:08
  • you need to use useEffect hook for this. – Noob Jan 19 '20 at 21:09
  • Works fine for me: https://codesandbox.io/s/eloquent-bas-bklnl –  Jan 19 '20 at 21:15
  • @ChrisG I'm sure his example would work fine, it doesn't unmount/mount any components upon state updating. I'm left wondering what the OP's actual code looks like and why it is remounting components so frequently instead of just updating state/props and simply rerendering. – Drew Reese Jan 19 '20 at 21:18
  • I can easily replicate the error by unmounting the component while its xhr is running. Upon finishing, the code tries to change the state of an unmounted component, which causes the error. So the fix isn't to batch stuff together, it's to keep the component mounted while the xhr is running. –  Jan 19 '20 at 22:18
  • Thanks for all the attention this is getting, @ChrisG I am getting the same error with your code sand box. – maxout Jan 19 '20 at 22:36
  • Yeah, I modified it to replicate the error. –  Jan 19 '20 at 22:46
  • @ChrisG, I see. I played around a bit more and it seems you're absolutely right. This actually does work. I think mine is failing because the "loading" state is actually coming from a context that is being consumed in the component. – maxout Jan 19 '20 at 23:07

3 Answers3

0

I think you can avoid that making loading as a standalone boolean variable instead of part of component's state. Another approach would be to use useEffect to delay API call until your loading variable change. This answer has got your covered. And lastly, you can make use of class components - setState() function accepts a callback as a second param - that was the way of making atomic state changes before hooks came into place.

IAmVisco
  • 373
  • 5
  • 14
  • 2
    Actually it was best practice to use componentDidUpdate not the second param callback. https://reactjs.org/docs/react-component.html#componentdidupdate – gadi tzkhori Jan 19 '20 at 21:58
  • Yes, use `componentDidUpdate` lifecycle function instead of going down the road of "nested hell" with using the `setState` callback parameter to issue further state updates. – Drew Reese Jan 19 '20 at 22:04
0

You could batch your states together into one composite state object and do it something like this...

import React, { useState } from "react";
import Button from "@material-ui/core/Button";

const Demo = () => {
  const [demoState, setDemoState] = useState({data: null, loading: false});

  const fetchData = () => {
    setDemoState({...demoState, loading: true});
    callAPI()
      .then( response => handleResponse(response)} );
  }

  const handleResponse = (response) => {
    if (response.status === 200) {
      setDemoState({data: response.data, loading: false});
    } else {
      setDemoState({...demoState, loading: false});
    }
  }

  return (
    <div>
      <Button disabled={loading} onClick={() => fetchData()}>
        Fetch Data
      </Button>
      <p>{JSON.stringify(demoState.data)}</p>
    </div>
  );
};

export default Demo;
Robert Lewis
  • 527
  • 1
  • 3
  • 8
0

You can combine loading and data to the one state. and also instead of loading use status.

for eg:

/** status possible values can be: "idle", "pending", "resolved", "rejected"*/

const [state, setState] = useState({status: "idle", data: null})

now you can easily change the status and base on that handle different things

TheEhsanSarshar
  • 2,677
  • 22
  • 41