最新消息:雨落星辰是一个专注网站SEO优化、网站SEO诊断、搜索引擎研究、网络营销推广、网站策划运营及站长类的自媒体原创博客

javascript - Prevent "React state update on unmounted component" warning when setting state on async callback

programmeradmin0浏览0评论

I want to trigger an asynchronous operation from an event handler on a ponent and after that operation pletes, update some UI state in that ponent. But the ponent may be removed from the DOM at any time, due to user navigating to another page. If that happens while the operation hasn't pleted yet, React logs this warning:

Warning: Can't perform a React state update on an unmounted ponent. 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.

Here's a reproducible example:

import { useState } from "react";
import ReactDOM from "react-dom";
// The router lib is a detail; just to simulate navigating away.
import { Link, Route, BrowserRouter } from "react-router-dom";

function ExampleButton() {
  const [submitting, setSubmitting] = useState(false);

  const handleClick = async () => {
    setSubmitting(true);
    await doStuff();
    setSubmitting(false);
  };

  return (
    <button onClick={handleClick} disabled={submitting}>
      {submitting ? "Submitting" : "Submit"}
    </button>
  );
}

function doStuff() {
  // Suppose this is a network request or some other async operation.
  return new Promise((resolve) => setTimeout(resolve, 2000));
}

function App() {
  return (
    <BrowserRouter>
      <nav>
        <Link to="/">Home</Link> | <Link to="/other">Other</Link>
      </nav>
      <Route path="/" exact>
        Click the button and go to "Other" page
        <br />
        <ExampleButton />
      </Route>
      <Route path="/other">Nothing interesting here</Route>
    </BrowserRouter>
  );
}

ReactDOM.render(<App />, document.querySelector("#root"));

You can see and run the example here. If you click the Submit button and then the "Other" link before 2 seconds pass, you should see the warning on the console.

Is there an idiomatic way or pattern for dealing with these scenarios where a state update is needed after an async operation?

What i've tried

My first attempt to fix this warning was to track whether the ponent has been unmounted or not using a mutable ref and a useEffect() hook:

function ExampleButton() {
  const [submitting, setSubmitting] = useState(false);
  const isMounted = useRef(true);

  useEffect(() => {
    return () => {
      isMounted.current = false;
    };
  }, []);

  const handleClick = async () => {
    setSubmitting(true);
    await doStuff();
    if (isMounted.current) setSubmitting(false);
  };

  return (
    <button onClick={handleClick} disabled={submitting}>
      {submitting ? "Submitting" : "Submit"}
    </button>
  );
}

Notice the conditional call to setSubmitting() after the doStuff() call.

This solution works, but i'm not too satisfied with it because:

  • It's quite bolerplate-ish. All the manual isMounted tracking seems like a low-level detail, unrelated to what this ponent is trying to do, and not something i'd want to repeat on other places that need a similar async operation.
  • Even if the boilerplate was hidden into a custom useIsMounted() hook, is seems that isMounted is an antipattern. Yes, the article is talking about the Component.prototype.isMounted method, which is not present on function ponents like the one i'm using here, but i'm basically emulating the same function with the isMounted ref.

Update: i've also seen the pattern of having a didCancel boolean variable inside the useEffect function, and using that to conditionally do stuff after the async function or not (because of an unmount or updated dependencies). I can see how this approach, or using a cancellable promise, would work nice in cases where the async operation is confined to a useEffect() and is triggered by ponent mount/update. But i cannot see how they would work in cases when the async operation is triggered on an event handler. The useEffect cleanup function should be able to see the didCancel variable, or the cancellable promise, so they would need to be lifted up to the ponent scope, making them virtually the same as the useRef approach mentioned above.

So i'm kind of lost on what to do here. Any help will be appreciated! :D

I want to trigger an asynchronous operation from an event handler on a ponent and after that operation pletes, update some UI state in that ponent. But the ponent may be removed from the DOM at any time, due to user navigating to another page. If that happens while the operation hasn't pleted yet, React logs this warning:

Warning: Can't perform a React state update on an unmounted ponent. 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.

Here's a reproducible example:

import { useState } from "react";
import ReactDOM from "react-dom";
// The router lib is a detail; just to simulate navigating away.
import { Link, Route, BrowserRouter } from "react-router-dom";

function ExampleButton() {
  const [submitting, setSubmitting] = useState(false);

  const handleClick = async () => {
    setSubmitting(true);
    await doStuff();
    setSubmitting(false);
  };

  return (
    <button onClick={handleClick} disabled={submitting}>
      {submitting ? "Submitting" : "Submit"}
    </button>
  );
}

function doStuff() {
  // Suppose this is a network request or some other async operation.
  return new Promise((resolve) => setTimeout(resolve, 2000));
}

function App() {
  return (
    <BrowserRouter>
      <nav>
        <Link to="/">Home</Link> | <Link to="/other">Other</Link>
      </nav>
      <Route path="/" exact>
        Click the button and go to "Other" page
        <br />
        <ExampleButton />
      </Route>
      <Route path="/other">Nothing interesting here</Route>
    </BrowserRouter>
  );
}

ReactDOM.render(<App />, document.querySelector("#root"));

You can see and run the example here. If you click the Submit button and then the "Other" link before 2 seconds pass, you should see the warning on the console.

Is there an idiomatic way or pattern for dealing with these scenarios where a state update is needed after an async operation?

What i've tried

My first attempt to fix this warning was to track whether the ponent has been unmounted or not using a mutable ref and a useEffect() hook:

function ExampleButton() {
  const [submitting, setSubmitting] = useState(false);
  const isMounted = useRef(true);

  useEffect(() => {
    return () => {
      isMounted.current = false;
    };
  }, []);

  const handleClick = async () => {
    setSubmitting(true);
    await doStuff();
    if (isMounted.current) setSubmitting(false);
  };

  return (
    <button onClick={handleClick} disabled={submitting}>
      {submitting ? "Submitting" : "Submit"}
    </button>
  );
}

Notice the conditional call to setSubmitting() after the doStuff() call.

This solution works, but i'm not too satisfied with it because:

  • It's quite bolerplate-ish. All the manual isMounted tracking seems like a low-level detail, unrelated to what this ponent is trying to do, and not something i'd want to repeat on other places that need a similar async operation.
  • Even if the boilerplate was hidden into a custom useIsMounted() hook, is seems that isMounted is an antipattern. Yes, the article is talking about the Component.prototype.isMounted method, which is not present on function ponents like the one i'm using here, but i'm basically emulating the same function with the isMounted ref.

Update: i've also seen the pattern of having a didCancel boolean variable inside the useEffect function, and using that to conditionally do stuff after the async function or not (because of an unmount or updated dependencies). I can see how this approach, or using a cancellable promise, would work nice in cases where the async operation is confined to a useEffect() and is triggered by ponent mount/update. But i cannot see how they would work in cases when the async operation is triggered on an event handler. The useEffect cleanup function should be able to see the didCancel variable, or the cancellable promise, so they would need to be lifted up to the ponent scope, making them virtually the same as the useRef approach mentioned above.

So i'm kind of lost on what to do here. Any help will be appreciated! :D

Share Improve this question edited Jun 23, 2021 at 7:00 epidemian asked Jun 23, 2021 at 6:02 epidemianepidemian 19.2k3 gold badges64 silver badges73 bronze badges 4
  • 3 stackoverflow./q/54327076/2333214 – T J Commented Jun 23, 2021 at 6:08
  • The documentation you linked suggests cancellable promises as a solution – T J Commented Jun 23, 2021 at 6:15
  • @TJ thanks for the link! and regarding cancellable promises, yes, i forgot to mention them in my question, but basically, i can see how they could be used when loading data on ponent mount/update with useEffect+cleanup, but i don't see how they could be used on an example like this where the async op is triggered on an event listener. how would the useEffect cleanup function know which promise to cancel? – epidemian Commented Jun 23, 2021 at 6:30
  • Lifting up variables to the ponent scope may not be the same as using useRef. The document stated that The difference between useRef() and creating a {current: ...} object yourself is that useRef will give you the same ref object on every render. – Quicksilver Commented Jun 23, 2021 at 9:12
Add a ment  | 

2 Answers 2

Reset to default 4

Indeed this.isMounted() is deprecated, and the usage of a _isMounted ref or instance variable is an anti pattern, notice that the usage of a _isMounted instance property was suggested as a temporary migration solution when this.isMounted() was deprecated because eventually it has the same problem of this.isMounted() which is leading to memory leaks.

The solution to that problem is that your ponent -whether a hook based or class based ponent, should clean it's async effects, and make sure that when the ponent is unmounted, nothing is still holding reference to the ponent or needs to run in the context of the ponent (hook based ponents), which makes the garbage collector able to collect it when it kicks in.

In your specific case you could do something like this

function ExampleButton() {
  const [submitting, setSubmitting] = useState(false);

  useEffect(() => {
    if (submitting) {
      // using an ad hoc cancelable promise, since ECMAScript still has no native way to cancel promises  
      // see example makeCancelable() definition on https://reactjs/blog/2015/12/16/ismounted-antipattern.html
      const cancelablePromise = makeCancelable(doStuff())
      // using then since to use await you either have to create an inline function or use an async iife
      cancelablePromise.promise.then(() => setSubmitting(false))
      return () => cancelablePromise.cancel(); // we return the cleanup function
    }

  }, [submitting]);

  const handleClick = () => {
    setSubmitting(true);
  };

  return (
    <button onClick={handleClick} disabled={submitting}>
      {submitting ? "Submitting" : "Submit"}
    </button>
  );
}

Notice now that no matter what happens when the ponent is unmounted there is no more functionality related to it that might/will run

The pattern to use to to set only if you're still mounted or not. You know if the ponent is still mounted as long as useEffect cleanup function was never called for the ponent.

export type IsMountedFunction = () => boolean;
export function useMounted(): IsMountedFunction {
  const mountedRef = useRef(false);
  useEffect(() => {
    mountedRef.current = true;
    return function useMountedEffectCleanup() {
      mountedRef.current = false;
    };
  }, []);
  return useCallback(() => mountedRef.current, [mountedRef]);
}

Given the above you the following hook that would handle the async then set state effect.

export function useAsyncSetEffect<T>(
  asyncFunction: () => Promise<T>,
  onSuccess: (asyncResult: T) => void,
  deps: DependencyList = []
): void {
  const isMounted = useMounted();
  useEffect((): ReturnType<EffectCallback> => {
    (async function wrapped() {
      const asyncResult = await asyncFunction();
      if (isMounted()) {
        onSuccess(asyncResult);
      }
    })();
  }, [asyncFunction, isMounted, onSuccess, ...deps]);
}

Sources and test are in

https://github./trajano/react-hooks/blob/master/src/useAsyncSetEffect/useAsyncSetEffect.ts

Note this does not get processed for correctness using ESLint react-hooks/exhaustive-deps

与本文相关的文章

发布评论

评论列表(0)

  1. 暂无评论