React ES6 change listeners simplified
Porting several React projects to ES6, the lack of autobind
is a serious annoyance. Previously you could rely on the compiler to automatically
call autoBind
on your event listeners allowing the following code to work as
you’d expect:
1
2
3
4
5
6
7
8
9
10
class MyComponent = React.createClass({
componentDidMount() {
// Autobind will make this work
MyFluxStore.addListener(this.onChange);
}
onChange(state) {
// 'this' is actually 'this'. No need for .bind
this.setState(state);
}
}
This is no longer the case when you have migrated to ES6, here we need to call bind
ourselves to make sure we get the proper behaviour.
1
2
3
4
5
6
7
8
9
10
class MyComponent extends React.Component {
componentDidMount() {
// the event listener needs an explicit bind
MyFluxStore.addListener(this.onChange.bind(this));
}
onChange(state) {
// 'this' is what we expect
this.setState(state);
}
}
The problem comes once the component in unmounted. Because bind
always returns
a new function, simply calling MyFluxStore.removeListener
will not work because
this.onChange.bind(this) != this.onChange.bind(this)
. The event handler will
remain on the store, leaking the listener and memory.
The solution here is to store away the event handler in the constructor as a member variable, and use that when binding / unbinding:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
class MyComponent extends React.Component {
constructor() {
super();
this._onChange = this.onChange.bind(this);
this.state = { ... };
}
componentDidMount() {
// the event listener needs an explicit bind
MyFluxStore.addListener(this.onChange);
}
componentWillUnmount() {
// Will remove the correct listener
MyFluxStore.removeListener(this._onChange);
}
onChange(state) { ... }
}
This leads to a lot of cruft code though. Every bind/unbind cycle needs to be
listed thrice in every component. Which is a drag. To solve this, I made an
extension to the BaseStore
class from flux. This changes the addChangeListener
and overrides EventEmitter.removeListener
in order to allow you to simple pass the same arguments
and it will find the listener you need to remove. Allowing you to rewrite the
code above as follows:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
class MyComponent extends React.Component {
constructor() {
super();
this.state = { ... };
}
componentDidMount() {
// Pass the arguments you want to bind after the function, store will handle binding
MyFluxStore.addListener(this.onChange, this);
}
componentWillUnmount() {
// Pass same arguments like in componentDidMount and it will find the function and remove it
MyFluxStore.removeListener(this.onChange, this);
}
onChange(state) { ... }
}
How does this magic work? It simple keeps a list in the store of what arguments were passed with a reference to the bound function. Then when you remove a listener it searches the internal list and remove that listener.
This carries no performance overhead when the listener is called, only a small loss when a listener is removed (because of the need to search for it in the list).
The code is available as a Github gist, feel free to comment there if you have remarks or comments.
Below is the excerpted addChangeListener
function. As can be seen, it keeps
a reference to the bound function by adding it to the global _listenerArguments
map, and adds an internal tag to the listener. If you look at
removeChangeListener
in the gist above
you will see it uses the bound listener to reverse the operation.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
addChangeListener(listener) {
if (typeof listener != 'function') {
throw new TypeError("'listener' argument must be a function");
}
if (arguments.length == 1) {
// Simple callback, just register it
this.on(Constants.CHANGE_EVENT, listener);
}
else {
// More complex, bind it, and keep a reference
let extraArguments = Array.prototype.slice.call(arguments, 1);
let boundListener = listener.bind.apply(listener, extraArguments);
// Remember the arguments we bound with
boundListener._listenerId = _listenerIds++;
_listenerArguments[boundListener._listenerId] = extraArguments;
// Register the listener
this.on(Constants.CHANGE_EVENT, boundListener);
}
},