Troubleshooting Adding and Removing EventListeners: with Arguments, Debounced, and in a React Class

The other day I was struggling with a bug that was caused by an eventListener not being properly removed in a React Component. I struggled for a long time with why the eventListener wasn't getting removed, and learned several things along the way I wanted to share.

The original component, and the issue

The code in question followed this pattern:

import ReactDOM from "react-dom";
import React, { Component } from "react";
import { debounce } from "../utils";

class myComponent extends Component {
  constructor(props) {
    super(props);
    this.scrollListener = this.scrollListener.bind(this);
  }

  // Acts on the user's scroll
  scrollListener() {
    // do stuff
  }

  componentDidMount() {
    window.addEventListener("scroll", debounce(this.scrollListener, 100));
  }

  componentWillUnmount() {
    window.removeEventListener("scroll", debounce(this.scrollListener, 100));
  }

  render() {
    return <myComponent />;
  }
}

export default myComponent;

To walk through what's going on:

import { debounce } from "../utils";

debounce is a hand-rolled debouncing function that we have in our repo - it's close to lodash's throttle function. It takes two arguments - the function we want to debounce, and the wait we want to specify.

constructor(props) {
  super(props);
  this.scrollListener = this.scrollListener.bind(this);
}

Binding scrollListener to this in the constructor so that it's possible to reference it - this is a common React pattern (though possibly outdated).

// Acts on the user's scroll
scrollListener() {
  // do stuff
}

The actual functionality of the compoent was in this function, but it was besides the point for this example. (In reality it triggered another page fetch when a user scrolled to the bottom of the component).

componentDidMount() {
  window.addEventListener("scroll", debounce(this.scrollListener, 100));
}

componentWillUnmount() {
  window.removeEventListener("scroll", debounce(this.scrollListener, 100));
}

When the component mounts and unmounts, we add and remove event listeners. And herein lies the problem.

Adding an Event Listener Function with Arguments

At first, when I was going through the code, this jumped out at me:

window.addEventListener("scroll", debounce(this.scrollListener, 100));

When we passed debounce as the event listener, we were passing our function to it, and a time that we needed to specify. We were passing arguments. As you may have found out, generally, you cannot pass arguments when adding event listener functions.

The existing code worked in terms of event listeners being added. But how? Because of JavaScript closure - this.scrollListener was defined in our class.

Where the bug was introduced

Our issue really stems from this line:

window.removeEventListener("scroll", debounce(this.scrollListener, 100));

At first glance, you would think that removeEventListener would find debounce(this.scrollListener, 100) and remove it. Sadly, this does not work.

When we initially used debounce in the addEventListener we created one instance of it. Using it again in removeEventListener actually creates a second instance of debounce,and it would therefore be impossible to match against the first, and actually, you know, remove the event listener.

How to Add and Remove an event listener, using an imported function, passing arguments in a way that works

After pulling my little remaining hair out all day, I stumbled upon this Stack Overflow Answer.

In short - when using a function in this way (especially for imported functions like this), you need to create a reference instance, and then use that to add and remove as an event listener. Where to keep that reference? The constructor.

import ReactDOM from "react-dom";
import React, { Component } from "react";
import { debounce } from "../utils";

class myComponent extends Component {
  constructor(props) {
    super(props);
    this.scrollListener = this.scrollListener.bind(this);
    this.debouncedScrollListener = debounce(this.scrollListener, 100);
  }

  // Acts on the user's scroll
  scrollListener() {
    // do stuff
  }

  componentDidMount() {
    window.addEventListener("scroll", this.debouncedScrollListener);
  }

  componentWillUnmount() {
    window.removeEventListener("scroll", this.debouncedScrollListener);
  }

  render() {
    return <myComponent />;
  }
}

export default myComponent;

The above code works to both add and remove an eventListener and we can pass arguments to our function.