Global listener patterns in React

Three patterns to propely handle global listeners in React

Author's image
Tamás Sallai
5 mins

Motivation

React properly handles it's components out of the box, but it is not always enough. You'll need a listener sometimes that is attached outside of the component, and it has to be removed along with the component. Failing to properly handle this can results in a few different kind of errors. This post demonstrates these errors and provide an overview of the three most fundamental patterns to deal with this.

Errors

Let's iterate on the errors first, that can potentially emerge when global listeners are not removed. Leaking, a warning, and buggy behavior are the three primary symptoms.

This page shows all of these erroneous behavior in action.

Leak

This one is the most straightforward. A lingering listener still consumes memory and CPU, even though it does not do anything meaningful. It can easily happen with a guard expression evaluating to false, which prevents bugs. It also makes these kind of errors hard to spot, as there are no obviously visible warnings.

Warning

React emits a warning to the console when an unmounted component calls setState. If a listener is still called and it modifies the component state, then we get a notification about it. This makes it quite easy to spot.

Calls a handler

An unmounted component still has access to the props object passed initially. Thus if a listener is still attached and calls a handler in the props, it is still invoked; it can easily results in buggy behavior. Whether or not you can spot it easily is based on the handling of such calls; it might not results in any palpable effect.

Patterns

Mount / UnMount

(Demo here) (Code here)

Attaching a listener in the componentDidMount and removing it in the componentWillUnmount callback is the simplest pattern. It is a static but a quite useful approach, useful when a component uses a global event throughout it's lifecycle. It only requires modifications in two places; thus it makes it less error-prone.

componentDidMount() {
	window.addEventListener("click", this.handleMouseClick);
},
componentWillUnmount() {
	window.removeEventListener("click", this.handleMouseClick);
},

In case you need a guard expression - for example for a drag and drop scenario to handle mouse up events -, then you should implement it in the handler itself. Alternatively, you can conditionally attach the listener only when needed; albeit it makes the approach more complex and yields little benefits.

handleMouseClick(event) {
	if (...guard expression...) {
		... do something
	}
},

Also, don't remove all listeners of a specific kind; it would prevent multiple instances of the component to exist concurrently.

Pros:

  • Simple to implement
  • Simple to reason
  • Not error-prone

Cons:

  • Static

State + DidUpdate

(Demo here) (Code here)

A more dynamic pattern, and also more aligned with the React philosophy. The component's state stores the listeners, and the lifecycle callbacks ensure that they are attached and removed when needed. Unfortunately, providing the componentDidUpdate callback is not sufficient, you also need to provide componentDidMount along with componentWillUnmount; they are not handled with the update. Because of this, it is best practice to move the logic out to a method.

componentDidMount() {
	this.handleListenersChange([], this.state.listeners);
},
componentWillUnmount() {
	this.handleListenersChange(this.state.listeners, []);
},
componentDidUpdate(prevProps, prevState) {
	this.handleListenersChange(prevState.listeners, this.state.listeners);
},

handleListenersChange(prevListeners, listeners) {
	_.each(_.difference(prevListeners, listeners), (listenerToRemove) => {
		window.removeEventListener("click", listenerToRemove);
	});
	_.each(_.difference(listeners, prevListeners), (listenerToAdd) => {
		window.addEventListener("click", listenerToAdd);
	});
},

With these callbacks in place, you can freely set the listeners in the state; the component itself will make sure they are attached and removed when needed. It will also make sure there will be no lingering handlers attached.

This versatile approach allows a dynamic array of listeners too. A downside is that you need to tap on three lifecycle callbacks at once; it might results in errors or erroneous behavior if you forget any of these.

Pros:

  • Simple to use
  • Able to handle dynamic array of listeners
  • The state describes the listeners

Cons:

  • You need to provide three lifecycle callbacks

Element

(Demo here) (Code here)

This approach uses a specialized React element that ties the lifecycle of the listener to the component's. It properly componentize everything involved, promoting reusability across the application.

const ClickListener = React.createClass({
	componentDidMount() {
		window.addEventListener("click", this.props.onClick);
	},
	componentWillUnmount() {
		window.removeEventListener("click", this.props.onClick);
	},
	render() {
		return null;
	}
});
<ClickListener onClick={this.handleClick}/>

This requires a minimum amount of code, and is the least error-prone. The listener element is easy to comprehend, and fully handles the lifecycle of the global listener. It is also a dynamic approach, as an arbitrary array of listeners can be attached at any time.

As a bonus point, the component hierarchy will properly reflect the listeners attached.

Pros:

  • Easy to implement
  • Only two lifecycle callbacks are needed, and they are isolated in a very small component
  • Least amount of code

Cons:

  • Each type of listeners needs an extra component

Summary

These are a few basic patterns, and all have strengths and weaknesses. Also you may use others, but I've found these three are the most fundamental.

I recommend that you use the first solution for simple cases, where a static amount of listeners are needed; and the last one if you need a more dynamic approach.

January 5, 2016
In this article