Search code examples
reactjsreact-hooksrace-conditionno-op

Trying to implement a cleanup in a useEffect to prevent no-op memory leak error


I am trying to update a piece of UI based on a conditional. The conditional is set by a database call in a separate component. It sometimes works, but often doesn't. When it doesn't work, it gets this error:

Warning: Can't perform a React state update on an unmounted component. This is a no-op, but it indicates a memory leak in your application. To fix, cancel all subscriptions and asynchronous tasks in a useEffect cleanup function.

I have followed other advice and tried to do a cleanup in a useEffect:

const [isPatched, setIsPatched] = useState<boolean>(false);

useEffect(() => {
    x.Log ? setPatched(true) : setPatched(false);
    return () => {
      setIsPatched(false);
    };
  }, []);

  const setPatched = (patched: boolean) => {
    setIsPatched(patched);
  };

Other component db call:

  useEffect(() => {
    if (disabled === true) {
      const handle = setTimeout(() => setDisabled(false), 7000);
      return () => clearTimeout(handle);
    }
  }, [disabled]);

  function handleClick() {
   [...]
    const updatePatchedX = async (id: string) => {
      //check if patched x already in db
      const content = await services.xData.getxContent(id);
      const xyToUpdated = content?.p[0] as T;

      if (!xToUpdated.log) {
        // add log property to indicate it is patched and put in DB
        xToUpdated.log = [
          { cId: cId ?? "", uId: uId, appliedAt: Date.now() },
        ];

        if (content) {
          await services.xData
            .updateOTxBook(id, content, uId)
            .then(() => {
              console.log("done");
              setPatched(true);
              setDisabled(true);
            });
        }
      }
    };

    updatePatchedX(notebookID);
  }

The UI is only fixed on refresh - not immediately, as the useEffect is supposed to achieve? Not sure where to go from here. Could be a race condition?


Solution

  • I have experienced this in the past and here's what I learned from it

    1. This is normally caused by this sequence of events:

      user clicks button => triggers API call => UI changes and the button gets unmounted => API call finishes and tries to update the state of a component that has been unmounted

    2. If the action could be canceled then the default recommendation of "To fix, cancel all subscriptions and asynchronous tasks in a useEffect cleanup function." makes sense; but in this case the API call cannot be cancelled.

    3. The only solution I've found to this is using a ref to track whether the component has been unmounted by the time the API call completes

    Below a snippet with just the relevant changes for simplicity

    const mainRef = useRef(null);
    
    function handleClick() {
       [...]
        const updatePatchedX = async (id: string) => {
          ...
              await services.xData
                .updateOTxBook(id, content, uId)
                .then(() => {
                  if (mainRef.current) {
                    console.log("done");
                    setPatched(true);
                    setDisabled(true);
                  }
                });
          ...
        };
    
        updatePatchedX(notebookID);
      }
    
    return (
      <div ref={mainRef}>.... <button onClick={handleClick}>...</button></div>
    );
    
    

    The above works because when the component gets unmounted the myRef references get emptied but you can still check its value when the API call eventually fulfills and before you use some setState function