facebook / react

The library for web and native user interfaces.
https://react.dev
MIT License
229.13k stars 46.88k forks source link

Deprecate `isMounted` #5465

Closed jimfb closed 7 years ago

jimfb commented 9 years ago

isMounted is already unavailable on ES6 classes, and we already have a warning saying we "might" remove them, but we don't actually have a github issue to deprecate them. As per our discussions today, we are basically agreed that we're going to start moving away from isMounted and deprecate it. We still need to figure out some good stories around promises (and related use cases).

This issue is to track progress toward that goal.

For background, please read:

quantizor commented 8 years ago

I don't agree with this. ES6 promises in particular cannot be reliably cancelled on componentWillUnmount, so removing the only way to check if the component is mounted before setState or another action is opening the way for a lot of hard to trace async bugs.

jimfb commented 8 years ago

@yaycmyk Thus the line:

We still need to figure out some good stories around promises (and related use cases).

Please read the background issues I listed, in particular: https://github.com/facebook/react/issues/2787#issuecomment-68738793

quantizor commented 8 years ago

I did read the comments. I just find the issues intractable.

nvartolomei commented 8 years ago

Why promises cant be reliable cancelled? Any sources/proofs/examples?

On Monday, November 16, 2015, Evan Jacobs notifications@github.com wrote:

I don't agree with this. ES6 promises in particular cannot be reliably cancelled on componentWillUnmount, so removing the only way to check if the component is mounted before setState or another action is opening the way for a lot of hard to trace async bugs.

quantizor commented 8 years ago

@nvartolomei Look at the ES6 promise spec.

zpao commented 8 years ago

This is a longer term goal, not something that is happening immediately. But we want to track the planning and discussions in a single place and not across comments in every issue when this comes up. We are aware of the problem of Promises currently being uncancellable which is a major reason we haven't already done this.

jimfb commented 8 years ago

@yaycmyk To over-simplify a very complex issue... the comments are saying... using isMounted to avoid setState for unmounted components doesn't actually solve the problem that the setState warning was trying to indicate - in fact, it just hides the problem. Also, calling setState as the result of a promise is a bit of an anti-pattern anyway, since it can cause race conditions which won't necessarily show up in testing. Thus we want to get rid of it, and figure out a "best practice" recommendation for using promises with React.

I agree the issues are a bit inscrutable, but that's largely because it's a complex issue that we're still figuring out and don't yet have a canned response for.

quantizor commented 8 years ago

calling setState as the result of a promise is a bit of an anti-pattern anyway, since it can cause race conditions which won't necessarily show up in testing

We can agree to disagree on that one. There are times when content is being fetched asynchronously and you don't want to have to go through a full-scale rerender to pop that content in once it is resolved. I use it specifically in an infinite table view implementation where a full virtual rerender would be unnecessary.

jimbolla commented 8 years ago

You might not be able to cancel a promise, but you can make it dereference the component on unmount, like so:

const SomeComponent = React.createClass({
    componentDidMount() {
        this.protect = protectFromUnmount();

        ajax(/* */).then(
            this.protect( // <-- barrier between the promise and the component
                response => {this.setState({thing: response.thing});}
            )
        );
    },
    componentWillUnmount() {
        this.protect.unmount();
    },
});

The important distinction is when this.protect.unmount() is called in componentWillUnmount, all callbacks get dereferenced, meaning the component gets dereferenced, and then when the promise completes, it just calls a no-op. This should prevent any memory leaks related to promises references unmounted components. source for protectFromUnmount

istarkov commented 8 years ago

This simple method can be used to add cancel to any promise

const makeCancelable = (promise) => {
  let hasCanceled_ = false;

  const wrappedPromise = new Promise((resolve, reject) => {
    promise.then((val) =>
      hasCanceled_ ? reject({isCanceled: true}) : resolve(val)
    );
    promise.catch((error) =>
      hasCanceled_ ? reject({isCanceled: true}) : reject(error)
    );
  });

  return {
    promise: wrappedPromise,
    cancel() {
      hasCanceled_ = true;
    },
  };
};

EDIT: Updated for correctness/completeness.

HOW TO USE

const somePromise = new Promise(r => setTimeout(r, 1000));

const cancelable = makeCancelable(somePromise);

cancelable
  .promise
  .then(() => console.log('resolved'))
  .catch(({isCanceled, ...error}) => console.log('isCanceled', isCanceled));

// Cancel promise
cancelable.cancel();
quantizor commented 8 years ago

Listing ways to sup-up ES6 promises to make them cancellable is besides the point. The intent should be to provide a solution that works WITH the spec rather than trying to work AROUND the spec.

ir-fuel commented 8 years ago

I agree. Instead of simply checking if the component is still mounted when we receive the promise result we have to resort to all kinds of magic so we can "unbind" our promise from the component it's supposed to set its result in, clearly fighting against the way promises are designed. To me it feels like overengineering a solution where a simple test is the easiest way to take care of this.

lasekio commented 8 years ago

We can keep simple checking just by:

React.createClass(function() {
  componentDidMount: function() {
    this._isMounted = true;

    ajax(/* */).then(this.handleResponse);
  }

  handleResponse: function(response) {
    if (!this._isMounted) return; // Protection

    /* */
  }

  componentWillUnmount: function() {
    this._isMounted = false;
  }
});
ir-fuel commented 8 years ago

This is of course my opinion, but it seems to me that async data loading with a promise inside a react component is such a common scenario that it should be covered by react, instead of having to write our own boilerplate code.

lasekio commented 8 years ago

The problem is, that to fallow the true mount state we must add listener when react will finish DOM mount process in each component (the same, which attach componentDidMount, if defined), but it will affect on perf, because we don't need to fallow it everywhere. Component dont listen DOM mount ready by default since componentDidMount is undefined.

RoyalIcing commented 8 years ago

What if setState could be passed a chained promise which resolves to the desired state changes? If the component unmounts, then if there are any pending promises their eventual result is ignored.

koistya commented 8 years ago

@istarkov nice pattern, like it! Here is slightly altered API for it:

// create a new promise
const [response, cancel] = await cancelable(fetch('/api/data'));

// cancel it
cancel();
dtertman commented 8 years ago

Since I'm new to React and reading docs, just to throw this out there : the Load Initial Data via Ajax tip uses .isMounted(), so the website disagrees with the website. It would be great to see a complete Tip about how to cancel the initial load in componentWillUnmount, maybe using @istarkov's pattern above.

jimfb commented 8 years ago

@dtertman Fixed in https://github.com/facebook/react/pull/5870, will be online when the docs get cherry-picked over.

dtertman commented 8 years ago

@jimfb thanks, not sure how I missed that in search.

vpontis commented 8 years ago

@istarkov not sure if this was intentional but your makeCancelable does not handle if the original promise fails. When the original promise is rejected, no handler gets called.

This does not seem ideal because you may still want to handle an error on the original promise.

Here is my proposal for a makeCancelable that handles a rejection in the original promise:

const makeCancelable = (promise) => {
  let hasCanceled_ = false;

  const wrappedPromise = new Promise((resolve, reject) => {
    promise.then((val) =>
      hasCanceled_ ? reject({isCanceled: true}) : resolve(val)
    );
    promise.catch((error) =>
      hasCanceled_ ? reject({isCanceled: true}) : reject(error)
    );
  });

  return {
    promise: wrappedPromise,
    cancel() {
      hasCanceled_ = true;
    },
  };
};

I'm not sure where I stand on if making cancelable promises is a good idea, but if we are going to make promises cancelable, we should preserve the underlying behavior :).

istarkov commented 8 years ago

@vpontis :+1:

vpontis commented 8 years ago

@istarkov your original post is referenced here: https://facebook.github.io/react/blog/2015/12/16/ismounted-antipattern.html

Want to update your post or should I message the author of the post?

jimfb commented 8 years ago

@vpontis Thanks, I'll fix! (https://github.com/facebook/react/pull/6152)

alangpierce commented 8 years ago

Hey @jimfb, fun running into you on the internet!

Another bug fix in the makeCancelable function: it can cause an UnhandledPromiseRejectionWarning in recent node versions (particularly when running tests with a new version of node). One of the changes in node 6.6.0 is that all unhandled promise rejections result in a warning. The existing code from @vpontis had separate then and catch calls on the same base promise. Effectively, this creates two promises, one which only handles success, and one which only handles errors. That means that if there is an error, the first promise will be viewed by node as an unhandled promise rejection.

The fix is pretty easy: just chain the two calls so that it makes one promise with both a success and error handler. Here's the fixed code:

const makeCancelable = (promise) => {
  let hasCanceled_ = false;

  const wrappedPromise = new Promise((resolve, reject) => {
    promise
      .then((val) =>
        hasCanceled_ ? reject({isCanceled: true}) : resolve(val)
      )
      .catch((error) =>
        hasCanceled_ ? reject({isCanceled: true}) : reject(error)
      );
  });

  return {
    promise: wrappedPromise,
    cancel() {
      hasCanceled_ = true;
    },
  };
};
STRML commented 7 years ago

@alangpierce That is very close to correct, but not quite; if resolve() or reject() synchronously throws for any reason on a resolved promise, both handlers will be called.

The solution is to use the .then(onFulfilled, onRejected) pattern:

const makeCancelable = (promise) => {
  let hasCanceled_ = false;

  const wrappedPromise = new Promise((resolve, reject) => {
    promise.then(
      (val) => hasCanceled_ ? reject({isCanceled: true}) : resolve(val),
      (error) => hasCanceled_ ? reject({isCanceled: true}) : reject(error)
    );
  });

  return {
    promise: wrappedPromise,
    cancel() {
      hasCanceled_ = true;
    },
  };
};
benmmurphy commented 7 years ago

isn't this makeCancelable solution effectively the same is the isMounted() call when looking at point 3 as to why isMounted() is deprecated:

Calling setState when a component is completely unmounted. This is a strong indication that an asynchronous callback isn't being properly cleaned up. Unfortunately, mainstream JS APIs makes it very easy to avoid cleaning up hanging asynchronous callbacks.

One callback isn't a big deal. However, that callback hangs on to objects and intermediate callbacks, promises and subscriptions. If alot of your components do this, you will quickly run into memory issues

makeCancellable just creates another promise which ends up holding a reference to functions which hold a reference to the component. the makeCancellable solution is just moving the boolean property isMounted into the promise.

in order to solve the GC issue you need to be nulling something out when cancel() is being called. otherwise you still have a reference chain from the async process to the component.

class CancellableDeferred {
  constructor(request) {
    this.deferred = $.Deferred();

    request.then((data) => {
      if (this.deferred != null) {
        this.deferred.resolve(data);
      }
    });

    request.fail((data) => {
      if (this.deferred != null) {
        this.deferred.reject(data);
      }
    });
  }

  cancel() {
    this.deferred = null;
  } 

  promise() {
    return this.deferred.promise();
  }
}

-> is how i would do it with jQuery deferred objects. i haven't really familiar with the Promise API so I don't know how it would look. Also, this doesn't reject the deferred when cancel() has been called and the deferred has not been resolved. Probably, people have a different opinion on how this should work.

so chain looks something like this:

AJAX Request -> Closure -> CancellableDeferredInstance -> JQuery Deferred -> Component

then after cancel it looks like this:

AJAX Request -> Closure -> CancellableDeferredInstance /object reference now null/ JQuery Deferred -> Component

so the AJAX Request is no longer preventing the Component from being GCd [assuming i haven't screwed up the implementation somewhere by accidentally holding a reference to deferred. yay closures....]

vpontis commented 7 years ago

Hi @benmmurphy, I am not super familiar with JS garbage compiling and it may work differently with React, but I have a different understanding.

makeCancellable allows a React component to be garbage collected when it is unmounted. I'll explain.

makeCancellable just creates another promise which ends up holding a reference to functions which hold a reference to the component. the makeCancellable solution is just moving the boolean property isMounted into the promise.

Without makeCancellable:

handleError() {
  if (this.isMounted()) {
    console.log('ERROR')
  }
}

With makeCancellable :

promise.then(...).fail((reason) => {
  if (reason.isCancelled) return;
  console.log('ERROR');
})

Without makeCancellable you still have a reference to this so the component can not be garbage collected when it is unmounted. But in the other case, the cancellable promise's fail handler is called as soon as the component is unmounted, so you don't have any references hanging around anymore.

benmmurphy commented 7 years ago

@vpontis

i have some nodejs code that illustrates the problem. The Foo component will only be GC'd once the asynchronous callback resolve has been set to null. For example lets say you fire off an ajax request that takes 30s to resolve then the component is unmounted. Then the component will not be GCd for 30s. This is one of the problems they are trying to solve with deprecating isMount().

npm install promise
npm install weak

node --expose-gc gc.js
first gc Foo {}
after first gc Foo {}
after resolve = null Foo {}
foo gc'd
after second gc {}

https://gist.github.com/benmmurphy/aaf35a44a6e8a1fbae1764ebed9917b6

EDIT:

sorry for talking past you but the first time i read the post i didn't understand the point you were trying to make but now i think I do. i think what you are trying to say is that because the error callback doesn't contain a reference to the component (or doesn't follow a reference to the component) then the component is not considered referenced by the promise. This is actually true. Well the first part is true. However, there are problems with this reasoning:

1) even though the error handler in your example doesn't have a reference to the component the then() callback usually will. For example the then handle will usually do this.setState(...). 2) even though the error handler in your example doesn't have a reference to the component most error handlers will. for example they will do something like:

promise.then(...).fail((reason) => {
  if (reason.isCancelled) return;
  console.log('ERROR');

  this.setState({error: true});
})

3) even though we know the code will not follow the then() callback and we know it will exit the function after check the isCancelled variable the GC does not know this.

and before anyone uses my example or something based off of it make sure you test that it actually GCs correctly. i haven't tested mine yet and it wouldn't surprise me if it didn't work because i've made some silly error :/

benmmurphy commented 7 years ago

in terms of the promise API this works for me in nodejs in terms of GC. though, i would prefer not to have the _resolve, _reject params near the closures because i'm not sure if this is guaranteed to work according to the JS spec or if it just happens to work because node is doing some optimizations. Can an implementation capture all variables visible or just the variables that are referenced in the closure? I dunno maybe someone who actually understands JS can chime in and explain :)

var makeCancelable = (promise) => {
  let resolve;
  let reject;

  const wrappedPromise = new Promise((_resolve, _reject) => {
    resolve = _resolve;
    reject = _reject;

    promise.then((val) => {
       if (resolve != null) resolve(val)
    });
    promise.catch((error) => {
       if (reject != null) reject(error)
    });
  });

  return {
    promise: wrappedPromise,
    cancel() {
      resolve = null;
      reject = null;
    },
  };
};
artyomtrityak commented 7 years ago

Will isMounted function be removed in 16.0 ?

BnayaZil commented 7 years ago

Suggestion for small improvement with @istarkov code:

const makeCancelable = (promise) => {
    let hasCanceled_ = false
    promise.then((val) =>
        hasCanceled_ ? reject({isCanceled: true}) : resolve(val)
    )
    .catch((error) =>
        hasCanceled_ ? reject({isCanceled: true}) : reject(error)
    )

    return {
        promise,
        cancel() {
            hasCanceled_ = true
        }
    }
}

It's just that the new promise is redundant.

ZebraFlesh commented 7 years ago

It's just that the new promise is redundant.

@BnayaZil You're calling resolve and reject functions, but it's unclear where those are from. Did you mean Promise.resolve and Promise.reject? In that case, you'd still be returning a new Promise.

mo commented 7 years ago

A few days ago a new API was added to the DOM specification that allows you to abort fetch() requests. This API is not implemented in any browser yet but I have created a polyfill for it available on NPM was "abortcontroller-polyfill". The polyfill does essentially the same thing as the code posted by @istarkov but allows you to transition with no code changes to the real browser API once it's implemented.

Details here: https://mo.github.io/2017/07/24/abort-fetch-abortcontroller-polyfill.html

aweary commented 7 years ago

Since React.createClass no longer exists in React 16 and the new create-react-class package includes a clear deprecation message for isMounted, I'm going to close this out.

hjylewis commented 7 years ago

I agree with @benmmurphy that @istarkov's solution is effectively the same as using isMounted() since it doesn't solve the garbage collection problem.

@benmmurphy's solution is closer but nulls out the wrong variables so the promise handlers are not dereferenced.

The key is passing a function up through the closure that dereferences the handlers:

const makeCancelable = promise => {
  let cancel = () => {};

  const wrappedPromise = new Promise((resolve, reject) => {
    cancel = () => {
      resolve = null;
      reject = null;
    };

    promise.then(
      val => {
        if (resolve) resolve(val);
      },
      error => {
        if (reject) reject(error);
      }
    );
  });

  wrappedPromise.cancel = cancel;
  return wrappedPromise;
};

Further explanation about why this solution allows for garbage collection and not the previous solutions can be found here.

I went ahead and turned this into an npm package, trashable. And since the use case is react, I made a HOC component that tracks promises and cancels them when the component gets unmounted, trashable-react.

pygy commented 7 years ago

Edit: my bad, I just looked at @hjylewis thrashable, and it does cancel promises as well. Still the pattern below is IMO a small improvement.

None of these solutions cancel Promises, which can be cancelled without any extension by absorbing a forever-pending promise.

function makeCancelable(promise) {
  let active = true;
  return {
    cancel() {active = false},
    promise: promise.then(
      value => active ? value : new Promise(()=>{}),
      reason => active ? reason : new Promise(()=>{})
    )
  }
}

// used as above:

const {promise, cancel} = makeCancelable(Promise.resolve("Hey!"))

promise.then((v) => console.log(v)) // never logs
cancel()

live here

There may be subtleties to iron out regarding GC and coffee has yet to kick in, but that pattern ensures that the promise returned is really cancelled and it can be made not to leak (I've implemented it in the past).

hjylewis commented 7 years ago

@pygy Thanks for the reply!

Unfortunately, your solution still doesn't allow for Garbage Collection. You've essentially just rewritten @istarkov's solution which uses a conditional.

You can test this easily by dropping this implementation into trashable and running the tests (the garbage collection test fails).

Your implementation also fails to properly handle errors.

adeelibr commented 6 years ago

It's 2018 is there an even better approach then the one's mentioned above?

npblubb commented 6 years ago

yes you can use some navigation frameworks that have a documentation twice the size of react native but is very professionel

dantman commented 5 years ago

These snippets for "canceling" a promise aren't that great IMHO. The cancelled promises will still not resolve until the original promise resolves. So memory cleanup won't happen till it would if you just used an isMounted trick.

A proper cancelable promise wrapper would have to make use of a second promise and Promise.race. i.e. Promise.race([originalPromise, cancelationPromise])

benmmurphy commented 5 years ago

@benmmurphy's solution is closer but nulls out the wrong variables so the promise handlers are not dereferenced.

I think my solution works but I don't know enough about what promises the javascript runtime gives to know for sure. If you run the solution under node in your test harness it correctly GCs the value. My solution assigned the the resolve/reject functions to a higher scope and then nulled these values out when cancel was called. However, the functions were still available in the lower scope but not referenced. I think modern javascript engines don't capture variables in a closure unless they are referenced. I think this used to be a big problem where people would accidentally create DOM leaks because they did stuff like: var element = findDOM(); element.addEventListener('click', function() {}); and element would be referenced in the closure even though it wasn't used in the closure.

drprasad-capillary commented 5 years ago

@hjylewis @benmmurphy why do we need to dereference handlers ?? after handlers excuted, garbage collection any way happens, right ??

benmmurphy commented 5 years ago

These snippets for "canceling" a promise aren't that great IMHO. The cancelled promises will still not resolve until the original promise resolves. So memory cleanup won't happen till it would if you just used an isMounted trick.

A proper cancelable promise wrapper would have to make use of a second promise and Promise.race. i.e. Promise.race([originalPromise, cancelationPromise])

@hjylewis and mine do you actually work you can verify it with node weak. but looking at them again i agree neither of them are idiosyncratic written promise code. as a promise user you would probable expect a 'cancelled' promise to resolve in the rejected state and neither of them do this. though, possibly in the case of a component this is a solution that would be easier to use because you don't have to write extra code to ignore the reject handler.

i think an idiosyncratic rejectable promise would use Promise.race([]) to build a cancellable promise. it works because when a promise becomes resolved the pending callbacks are deleted so at the point there would be no reference chain from the browser network to your component because there would be no longer a reference between the race promise and the component.

ketysek commented 5 years ago

I'm curious if it's somehow possible to use Promise.all() with those cancelable promises and avoid uncaught errors in browsers console ... because I'm able to catch only first cancellation error, others remains uncaught.

adeelibr commented 5 years ago

It's 2018 is there an even better approach then the one's mentioned above?

Any better approach to cancel a promise execution i.e, setTimeout, API Calls etc.. It's 2019 😭 😞

adeelibr commented 5 years ago

There is Promise cancellation thread going on TC39, (I think) it's of relevance here (maybe .. not sure) https://github.com/tc39/proposal-cancellation/issues/24

paul4156 commented 5 years ago

Any better approach to cancel a promise execution i.e, setTimeout, API Calls etc.. It's 2019 😭 😞

Are we looking for something like

const promise = new Promise(r => setTimeout(r, 1000))
  .then(() => console.log('resolved'))
  .catch(()=> console.log('error'))
  .canceled(() => console.log('canceled'));

// Cancel promise
promise.cancel();
impulsgraw commented 3 years ago

Ladies, as of 2021 I guess everyone better take a look at rxjs instead of reinventing the wheel again and again...

baldwinjj commented 2 years ago

I know this is an old thread but I figure I'd chime in just in case anyone else comes across it looking for a generic solution to cancel promises as I did. @dantman is right, @istarkov's solution doesn't behave as I was expecting because the canceled promise doesn't reject until after the wrapped promise has resolved. Here is a solution that better matches my (and perhaps others') expectations. It immediately rejects whenever cancel is called.

const makeCancelable = (promise) => {
  let onCancel;
  const cancelPromise = new Promise((resolve, reject) => {
    onCancel = () => reject({ isCanceled: true });
  });
  return {
    promise: Promise.race([promise, cancelPromise]),
    cancel: onCancel,
  };
};

Here is an example of both solutions in action: https://stackblitz.com/edit/js-2kfvyt?file=index.js