Dismiss
  • Toggle Theme
  • View as Mobile

Improving Event Listener DX

Event listeners make the browser world go round!

The addEventListener and removeEventListener functions make everything worthwhile happen on the web. So you will be using them ALOT.

Adding 1 or 2 listeners is no big deal but they tend stack up quick. For every add there is a remove. So 2 listeners will translate to 4 calls.

This is to keep the DOM in a perfect, zen-like, balance and also to avoid memory leaks.

Fast forward a couple of months... you have an amazing app with tons of features. Your mom is proud, but you dad is disappointed in how messy your code has become!

Everywhere in your app, you have some kind of variation of this:

function doAmazingThing() {
  alert('Wow')
}

const myButton = document.getElementById('my-button-id')
myButton.addEventListener('click', doAmazingThing)

//... elsewhere in app
const myButton = document.getElementById('my-button-id')
myButton.removeEventListener('click', doAmazingThing)
Note

The above example is using vanillaJS but the same problem exists in React or whatever framework you are using. You will need to clean up events when components unmount.

This code is brittle and lacks solid ergonomics.

Let's make it better!

Simplify event removal

Listeners need to get removed or you could run into memory leaks or duplicate events firing more handlers than you want.

removeEventListener will detach the event from the node it's attached to. This requires the same function that was passed to the addEventListener.

By default it would look like this:

const myButton = document.getElementById('my-button-id')
myButton.removeEventListener('click', doAmazingThing)

But instead let's wrap addEventListener and return a cleanup function that includes the corresponding removeEventListener.

function addClickHandler(element, eventName, fn) {
  element.addEventListener(eventName, fn)
  /* return the cleanup function */
  return () => {
    element.removeEventListener(eventName, fn)
  }
}

/* Attach button click handler */
const myButton = document.getElementById('my-button-id')
const doAmazingThing = () => alert('Wow')
const cleanup = addClickHandler(myButton, 'click', doAmazingThing)

/* ...Later in app, remove click handler */
cleanup()

As you can see this makes handling the listener much easier. It also means you can unsubscribe this listener by passing around this reference to other functions/components.

Simplify event re-attachment

Now that we have a simple way of removing listeners... what about if we need to reattach them?

Well we can make the same thing happen with a little bit of magic.

We can have the cleanup function return the original attachment function.

/* Recursive attach/detach events */
function addClickHandler(element, eventName, fn) {
  function attach() {
    element.addEventListener(eventName, fn)
    /* return the cleanup function */
    return remove
  }
  function remove() {
    element.removeEventListener(eventName, fn)
    /* return the reattach function */
    return attach
  }
  /* Attach listener and return recursive detach/attach fn */
  return attach()
}

/* Attach button click handler */
const myButton = document.getElementById('my-button-id')
const doAmazingThing = () => alert('Wow')
const cleanup = addClickHandler(myButton, 'click', doAmazingThing)

/* ...Later in app, remove click handler */
const reAttachFn = cleanup()

/* ...Later in app, add the click handler back to button */
const cleanupTwo = reAttachFn()

/* ...Later in app, remove click handler */
const reAttachFnTwo = cleanupTwo()

//.... etc. You get the idea

We now have a generic function that will return this eventListener toggler.

I quite liked this pattern of a function recursively returning the inverse operation of itself. We've streamlined our attach/detach functionality behind this tiny utility.

Simplify DOM attachment

To attach an event you need to have a valid DOM node reference from our framework or from getElementById/querySelector. This means you will probably have some null checks to ensure it exists before trying to add.

const myButton = document.getElementById('my-button-id')
/* Verify button exists before trying to add a listener */
if (myButton) {
  myButton.addEventListener('click', doAmazingThing)
}

This is fine but we can do better.

The desired API we want is:

addListener(nodeOrSelectorStr, eventName, callbackFunction)

Under the hood, addListener & removeListener should null check for the existence of the elements before trying to attach a listener to them.

nodeOrSelectorStr handles dom node references, strings of selectors and arrays of both.

import { addListener } from '@analytics/listener-utils'

/* Single selector string */
const stringSelector = '.my-button'
addListener(stringSelector, 'click', () => {
  console.log('will fire on click')
})

/* Comma separated list of selector strings */
const commaSeparated = '.my-button, .nav-item'
addListener(commaSeparated, 'click', () => {
  console.log('will fire on click on either .my-button or .nav-item')
})

/* Array of selector strings */
const arrayOfSelectors = ['.my-button', '.nav-item']
addListener(arrayOfSelectors, 'click', () => {
  console.log('will fire on click')
})

/* Single DomNode */
const myButton = document.getElementById('my-button-id')
addListener(myButton, 'click', () => {
  console.log('will fire on click')
})

/* Array of DomNodes */
const myButton = document.getElementById('my-button-id')
const myOtherButton = document.getElementById('other-button-id')
addListener([myButton, myOtherButton], 'click', () => {
  console.log('will fire on click of either button')
})

Ah, very nice polymorphic api. Quite hard to mess this up.

Simplify multiple event types

Sometimes you want to listener to multiple types of events on a node.

addEventListener accepts a single event by default so you need to do something like this:

const events = "click mouseover"
events.split(" ").forEach((event) => {
  window.addEventListener(event, mouseMoveHandler)
})

But we can do better and make it easier to add multiple events.

import { addListener } from '@analytics/listener-utils'

/* Single event string */
const stringEvent = 'click'
addListener('#heading', stringEvent, (e) => {
  console.log('will fire on click')
})

/* Comma separated list of event strings */
const commaSeparatedEvents = 'click, mouseover'
addListener('#heading', commaSeparatedEvents, () => {
  console.log('will fire on click or mouseover')
})

/* Array of event strings */
const arrayOfEvents = ['mouseover', 'click']
addListener('#heading', arrayOfEvents, () => {
  console.log('will fire on click or mouseover')
})

Maintain browser event setting options

Any kind of abstraction you have over a native browser API should try and mirror the underlying functionality as much as possible.

There's nothing worse than needing some functionality that the base layer of something provides but whatever abstraction we are using doesn't let us.

Many devs call this an escape hatch and it's a great way to design your tools for simplicity while providing full functionality to advanced users.

Base addEventListener has the following API

addEventListener(type, listener)
addEventListener(type, listener, options)
addEventListener(type, listener, useCapture)

Our API will also allow for these options to be passed in.

Adding once

The base addEventListener api has a once option that

once is a boolean value indicating that the listener should be invoked at most once after being added

This is handy for various scenarios but won't work when we pass in multiple selectors or elements.

So instead we use our own once implementation here.

function once(fn, context) {
  var result
  return function() {
    if (fn) {
      result = fn.apply(context || this, arguments)
      fn = null
    }
    return result
  }
}

General usage:

function foo() {
  console.log('log hello')
}

const fooOnce = once(foo)
fooOnce() // log hello
fooOnce() // ... nothing it's onced

Verify we are in browser

Whenever we make something that uses browser APIs its a good idea to verify we are in fact in the browser. For example if we try to use document.getElementById or element.addEventListener in a function it will throw if it's being run inside of a "server" context.

const isBrowser = typeof window !== 'undefined'
const noOp = () => {}

function addClickHandler(element, eventName, fn) {
  if (!isBrowser) {
    return noOp /* SSR support noOp fn */
  }
  element.addEventListener(eventName, fn)
  /* return the cleanup function */
  return () => {
    element.removeEventListener(eventName, fn)
  }
}

Browser Fallbacks

Depending on your users, you will want to write your code in a robust way in order to support older browser implementations.

/* Fallback for older browsers IE <=8 */
element['on' + EVENT_NAME] = fns // addEventListener fallback
element['on' + EVENT_NAME] = null // removeEventListener fallback

Never know who is running on a super old machine 😅

This is optional, but I like handling these cases when they are pretty simple. This is in the final implementation of the library below.

Real world scenario

Here is the pattern in real world scenario:

Let's say you have a button that when clicked calls the star wars API to list out their favorite characters.

When the user clicks the button we want to only call the API once. We can achieve this quite simply by disabling the click handler when the fetch call is made.

You don't need a complicated framework or state to avoid duplicate calls to your API.

/* Detach self example */
const apiButton = document.querySelector('#api')
const disableFetchListener = addListener(apiButton, 'click', async () => {
  // Fetch in progress disable click handler to avoid duplicate calls
  const reAddAPIClickHandler = disableFetchListener()
  console.log('API button wont call api again until the request has finished')
  // Make API call
  fetch(`https://swapi.dev/api/people/?search=l`)
    .then((response) => {
      return response.json()
    })
    .then((json) => {
      console.log("data", json.results)
      // Success! Reattach event handler
      reAddAPIClickHandler()
      console.log('Ready to make another API call')
    })
    .catch((err) => {
      console.log('API error', err)
      // Error! Reattach event handler
      reAddAPIClickHandler()
      console.log('Ready to make another API call')
    })
})

Packaging this up

Wow fancy fancy. Now event handlers are a lil bit easier to work with.

You can see the live demo and view its source on github.

The full implementation is in the @analytics/listener-utils package or below.

This is the library implementation with some code golfing to make it as tiny as possible.

import { isBrowser, isString, isFunction, ensureArray, noOp } from '@analytics/type-utils'

const EVENT = 'Event'
const EventListener = EVENT + 'Listener'

function createListener(add) {
  return (els, evts, callback, opts) => {
    const handler = callback || noOp
    /* SSR support */
    if (!isBrowser) return handler
    
    const options = opts || false
    const events = toArray(evts)
    const elements = toArray(els, true)
    let fns = []
    /* Throw if no element found */
    if (!elements.length) throw new Error('noElements')
    /* Throw if no events found */
    if (!events.length) throw new Error('no' + EVENT)
  
    function smartAttach(isAdd) {
      const method = (isAdd) ? 'add' + EventListener : 'remove' + EventListener
      // console.log((isAdd) ? '>> setup called' : '>> teardown')
      // console.log(`>>> with ${method}`)
      if (isAdd) fns = []
      
      // Apply to all elements
      for (let i = 0; i < elements.length; i++) {
        const el = elements[i]
        fns[i] = (isAdd ? oncify(handler, options) : fns[i] || handler)
        // Apply to all events
        for (let n = 0; n < events.length; n++) {
          if (el[method]) {
            el[method](events[n], fns[i], options)
          } else {
            /* Fallback for older browsers IE <=8 */
            el['on' + events[n]] = (isAdd) ? fns[i] : null
          }
        }
      }
      // return opposite function with inverse event handler
      return smartAttach.bind(null, !isAdd)
    }

    return smartAttach(add)
  }
}

function toArray(obj, isSelector) {
  // Split string
  if (isString(obj)) {
    return isSelector ? toArray(document.querySelectorAll(obj)) : obj.split(' ').map(e => e.trim())
  }
  // Convert NodeList to Array
  if (NodeList.prototype.isPrototypeOf(obj)) {
    const array = []
    for (var i = obj.length >>> 0; i--;) { // iterate backwards ensuring that length is an UInt32
      array[i] = obj[i]
    }
    return array
  }
  // Is Array, return it OR Convert single element to Array
  // return isArray(obj) ? obj : [ obj ]
  return ensureArray(obj)
}

function oncify(handler, opts) {
  return (opts && opts.once) ? once(handler) : handler
}


/**
 * Run function once
 * @param {Function} fn - Function to run just once
 * @param {*} [context] - Extend function context
 * @returns 
 */
export function once(fn, context) {
  var result
  return function() {
    if (fn) {
      result = fn.apply(context || this, arguments)
      fn = null
    }
    return result
  }
}

/**
 * Element selector
 * @typedef {(string|Node|NodeList|EventTarget|null)} Selector
 */

/**
 * Event to listen to 
 * @typedef {(string|string[])} EventType
 */

/**
 * Cleanup event listener 
 * @callback RemoveListener
 * @returns {AttachListener}
 */

/**
 * ReAttach event listener
 * @callback AttachListener
 * @returns {RemoveListener}
 */

/**
 * Add an event listener
 * @callback AddEventListener
 * @param {Selector}  elements  - Element(s) to attach event(s) to.
 * @param {EventType} eventType - Event(s) to listen to 
 * @param {Function}  [handler] - Function to fire
 * @param {Object}    [options] - Event listener options
 * @param {Boolean}   [options.once] - Trigger handler just once
 * @returns {RemoveListener}
 */

/** @type {AddEventListener} */
export const addListener = createListener(EVENT) 

/**
 * Remove an event listener
 * @callback RemoveEventListener
 * @param {Selector}  elements  - Element(s) to remove event(s) from.
 * @param {EventType} eventType - Event(s) to remove
 * @param {Function}  [handler] - Function to remove
 * @param {Object}    [options] - Event listener options
 * @param {Boolean}   [options.once] - Trigger handler just once
 * @returns {AttachListener}
 */

/** @type {RemoveEventListener} */
export const removeListener = createListener()