Stores' change listeners not getting removed on componentWillUnmount?
Asked Answered
A

7

7

I am coding a simple app on reactjs-flux and everything works fine except I am receiving a warning from reactjs telling me that I am calling setState on unmounted components.

I have figured out this is because changelisteners to which components are hooked are not being removed from the store on componentWillUnmount. I know it because when I print the list of listeners from Eventemitter I see the listener which was supposed to be destroyed still there, and the list grows larger as I mount/unmount the same component several times.

I paste code from my BaseStore:

import Constants from '../core/Constants';
import {EventEmitter} from 'events';

class BaseStore extends EventEmitter {
  // Allow Controller-View to register itself with store
  addChangeListener(callback) {
    this.on(Constants.CHANGE_EVENT, callback);
  }

  removeChangeListener(callback) {
    this.removeListener(Constants.CHANGE_EVENT, callback);
  }

  // triggers change listener above, firing controller-view callback
  emitChange() {
    this.emit(Constants.CHANGE_EVENT);
  }
}

export default BaseStore;

I paste the relevant code from a component experiencing this bug (it happens with all components, though):

@AuthenticatedComponent
class ProductsPage extends React.Component {
  static propTypes = {
    accessToken: PropTypes.string
  };

  constructor() {
    super();
    this._productBatch;
    this._productBatchesNum;
    this._activeProductBatch;
    this._productBlacklist;
    this._searchById;
    this._searchingById;
    this.state = this._getStateFromStore();
  }

  componentDidMount() {
    ProductsStore.addChangeListener(this._onChange.bind(this));
  }

  componentWillUnmount() {
    ProductsStore.removeChangeListener(this._onChange.bind(this));
  }

  _onChange() {
    this.setState(this._getStateFromStore());
  }
}

This is driving me pretty nuts at this point. Any ideas?

Thank you!

Asbestos answered 20/8, 2015 at 14:39 Comment(2)
Are you certain componentWillUnmount() is firing?P
yes, I placed console.logs on all componentWillUnmount from all my components and they are being fired.Asbestos
C
21

Short version: expect(f.bind(this)).not.toBe(f.bind(this));

Longer explanation:

The cause of the issue is that EventEmitter.removeListener requires that you pass a function you have previously registered with EventEmitter.addListener. If you pass a reference to any other function, it is a silent no-op.

In your code, you are passing this._onChange.bind(this) to addListener. bind returns a new function that is bound to this. You are then discarding the reference to that bound function. Then you try to remove another new function created by a bind call, and it's a no op, since that was never added.

React.createClass auto-binds methods. In ES6, you need to manually bind in your constructor:

@AuthenticatedComponent
class ProductsPage extends React.Component {
  static propTypes = {
    accessToken: PropTypes.string
  };

  constructor() {
    super();
    this._productBatch;
    this._productBatchesNum;
    this._activeProductBatch;
    this._productBlacklist;
    this._searchById;
    this._searchingById;
    this.state = this._getStateFromStore();
    // Bind listeners (you can write an autoBind(this);
    this._onChange = this._onChange.bind(this);
  }

  componentDidMount() {
    // listener pre-bound into a fixed function reference. Add it
    ProductsStore.addChangeListener(this._onChange);
  }

  componentWillUnmount() {
    // Remove same function reference that was added
    ProductsStore.removeChangeListener(this._onChange);
  }

  _onChange() {
    this.setState(this._getStateFromStore());
  }

There are various ways of simplifying binding - you could use an ES7 @autobind method decorator (e.g. autobind-decorator on npm), or write an autoBind function that you call in the constructor with autoBind(this);.

In ES7, you will (hopefully) be able to use class properties for a more convenient syntax. You can enable this in Babel if you like as part of the stage-1 proposal http://babeljs.io/docs/plugins/transform-class-properties/ . Then, you just declare your event listener methods as class properties rather than methods:

_onChange = () => {
    this.setState(this._getStateFromStore());
}

Because the initializer for _onChange is invoked in the context of the constructor, the arrow function auto-binds this to the class instance so you can just pass this._onChange as an event handler without needing to manually bind it.

Classicist answered 10/12, 2015 at 11:18 Comment(3)
The technique should work fine. Have you debugged it to see what's going on? Or could you post a link to your code?Classicist
Been a long time, but TomW is absolutely right here ;)Asbestos
Works perfect for me. But I don't understand why it works for other stores without this solution but for one store I really need this. Strange behaviour....Leela
A
4

So I have found the solution, it turns out I only had to assign this._onChange.bind(this) to an internal property before passing it as an argument to removechangelistener and addchangelistener. Here is the solution:

  componentDidMount() {
    this.changeListener = this._onChange.bind(this);
    ProductsStore.addChangeListener(this.changeListener);
    this._showProducts();
  }
  componentWillUnmount() {
    ProductsStore.removeChangeListener(this.changeListener);
  }

I do not know, however, why this solves the issue. Any ideas?

Asbestos answered 21/8, 2015 at 9:30 Comment(2)
Hi, since the last time I have stumbled upon this error a few times and I have found the following solution: componentWillUnmount() { this.isUnmounted = true; } And, from now on, check every setState request like this: if (!this.isUnmounted) { this.setState({ isLoading: false, isDisabled: false }); }. This comes from the comments on this post, btw: jaketrent.com/post/set-state-in-callbacks-in-reactAsbestos
Hello Gerard. I ended up using something else. I used a library called eventemitter3 instead of node's events. This library attaches a 'this' to each callback you give it and you also give it the same 'this' on the unmount function of the component. Because the callbacks are associated with your 'this' the correct callback function is 'unlistened' where on node's events it doesn't match the function you unregister and thus keeps listening.Amandaamandi
P
1
Warning: setState(...): Can only update a mounted or mounting component. This usually means you called setState() on an unmounted component. This is a no-op. Please check the code for the exports component.

I am using the exact same implementation across multiple react components. i.e. this is repeated across several .jsx components.

componentDidMount: function() {
    console.log('DidMount- Component 1');
   ViewStateStore.addChangeListener(this._onChange);
},

componentWillUnmount: function() {
    console.log('DidUnMount- Component 1');
   ViewStateStore.removeChangeListener(this._onChange);
},

_onChange:function()
{
    console.log('SetState- Component 1');
    this.setState(getStateFromStores());
},

Possible Solution

Currently the following is working out for me, but it has been a little temperamental. Wrap the call back in a function/named-function.

ViewStateStore.addChangeListener(function (){this._onChange});

one might also try

ViewStateStore.addChangeListener(function named(){this._onChange});

Theory

EventEmitter is for some reason getting confused identifying the callback to remove. Using a named function is perhaps helping with that.

Psychosis answered 24/11, 2015 at 12:6 Comment(1)
Effectively the same as the current answer, just marginally different implementation.Psychosis
S
0

As you already got to know the solution here, I will try to explain what's happening.
As per ES5 standard, we used to write following code to add and remove listener.

componentWillMount: function() {
    BaseStore.addChangeListener("ON_API_SUCCESS", this._updateStore);
},
componentWillUnmount: function() {
    BaseStore.removeChangeListener("ON_API_SUCCESS", this._updateStore);
}

In above code, memory reference for the callback function (ie: this._updateStore) is same. So, removeChangeListener will look for reference and will remove it.

Since, ES6 standard lacks autobinding this by default you have to bind this explicitly to the function.

Note: Bind method returns new reference for the callback. Refer here for more info about bind

This is where problem occurs. When we do this._updateStore.bind(this), bind method returns new reference for that function. So, the reference that you have sent as an argument to addChangeListener is not same as the one in removeChangeListener method.

this._updateStore.bind(this) != this._updateStore.bind(this)

Solution:
There are two ways to solve this problem.
1. Store the event handler (ie: this._updateStore) in constructor as a member variable. (Your solution)
2. Create a custom changeListener function in store that will bind this for you. (Source: here)

Solution 1 explanation:

constructor (props) {
    super(props);
    /* Here we are binding "this" to _updateStore and storing 
    that inside _updateStoreHandler member */

    this._updateStoreHandler = this._updateStore.bind(this);

    /* Now we gonna user _updateStoreHandler's reference for 
    adding and removing change listener */
    this.state = {
        data: []
    };
}

componentWillMount () {
    /* Here we are using member "_updateStoreHandler" to add listener */
    BaseStore.addChangeListener("ON_STORE_UPDATE", this._updateStoreHandler);
}
componentWillUnmount () {
    /* Here we are using member "_updateStoreHandler" to remove listener */
    BaseStore.removeChangeListener("ON_STORE_UPDATE", this._updateStoreHandler);
}

In above code, we are binding this to _updateStore function and assigning that to a member inside constructor. Later we are using that member to add and remove change listener.

Solution 2 explanation: In this method, we modify BaseStore functionalities. Idea is to modify addChangeListener function in BaseStore to receive second argument this and inside that function we are binding this to the callback and storing that reference, so that while removing change listener we can remove with that reference.

You can find complete code gist here and source here.

Saxe answered 20/8, 2015 at 14:40 Comment(0)
J
0

Try removing the .bind(this) from your addChangeListener and removeChangeListener. They are already bound to your component when they get called.

Jermyn answered 21/8, 2015 at 1:2 Comment(2)
nope, since onchange is called inside a productsstore method, value of this refers to the productstore object. I have confirmed this by printing the value of this with and without bind(this).Asbestos
Hi, since the last time I have stumbled upon this error a few times and I have found the following solution: componentWillUnmount() { this.isUnmounted = true; } And, from now on, check every setState request like this: if (!this.isUnmounted) { this.setState({ isLoading: false, isDisabled: false }); }. This comes from the comments on this post, btw: jaketrent.com/post/set-state-in-callbacks-in-reactAsbestos
U
0

I decided it so

class Tooltip extends React.Component {
    constructor (props) {
        super(props);

        this.state = {
             handleOutsideClick: this.handleOutsideClick.bind(this)
        };
    }

    componentDidMount () {
         window.addEventListener('click', this.state.handleOutsideClick);
    }

    componentWillUnmount () {
         window.removeEventListener('click', this.state.handleOutsideClick);
    }
}
Unbalanced answered 1/12, 2015 at 11:56 Comment(0)
A
0

This is a es6 problem. React.createClass binds 'this' properly for all function defined inside its scope.

For es6, you have to do something yourself to bind the right 'this'. Calling bind(this) however, creates a new function each time, and passing its return value to removeChangeListener won't match the function passed into addChangeListener created by an earlier bind(this) call.

I see one solution here where bind(this) is called once for each function and the return value is saved and re-used later. That'll work fine. A more popular and slightly cleaner solution is using es6's arrow function.

componentDidMount() {
    ProductsStore.addChangeListener(() => { this._onChange() });
}

componentWillUnmount() {
    ProductsStore.removeChangeListener(() => { this._onChange());
}

Arrow functions capture the 'this' of the enclosing context without creating new functions each time. It's sort of designed for stuff like this.

Adelladella answered 10/12, 2015 at 4:41 Comment(1)
That won't work either - you're removing a different listener to the one you addedClassicist

© 2022 - 2024 — McMap. All rights reserved.