Better Scroll and Resize Event Handling

Solomon Hawk, Senior Developer

Article Category: #Code

Posted on

If you've written any non-trivial JavaScript targeted at the browser, then you've probably had to wire up a scroll or resize handler to the window in order to trigger some behavior in response to user scrolling or viewport size changes. Since you're a Good Programmer, you're also aware that these events tend to fire rapidly. As a result, if you attach a handler that is either computationally expensive or requires reading from or writing to the DOM, you're gonna have a bad time.

Enter: throttle and debounce functions.

Throttle results in rate-limited function invocations. A throttled function is executed no more than one time in a given interval regardless of how many times it is invoked.

Debounce results in deferred function invocation. A debounced function will not be executed until a configured delay after the last invocation. Alternatively, with an `immediate` flag, it can also be invoked at the first call.

A Visual Illustration of Throttle vs. Debounce

A Better Way

We use these utilities frequently in our apps and, while not all use cases require the same setup, many of my projects end up using a variation of the following pattern: 


// windowEvents.js

/*
 * One caveat: many libraries will provide throttle,
 * debounce, and event bindings for you. This example
 * assumes nothing about your project's requirements.
 * I recommend leveraging any existing libs you are
 * using before pulling in something new.
 */

var events = require('dom-events'); // npm module
var throttle = require('./util/throttle'); // local module
var debounce = require('./util/debounce'); // local module

// cache dom
var body = document.body;

// calculate viewport dimensions
var getDimensions = function() {
 var rect = body.getBoundingClientRect();

 return {
 height: rect.bottom - rect.top;
 width: rect.right - rect.left;
 }
}

// define scroll and resize handlers using throttle and debounce
var listeners = {
 scroll: {
 throttled: throttle(function() {
 events.emit(window, 'scroll.throttled', { scroll: body.scrollTop });
 }, 100),

 debounced: debounce(function() {
 events.emit(window, 'scroll.debounced', { scroll: body.scrollTop });
 }, 100)
 },

 resize: {
 throttled: throttle(function() {
 events.emit(window, 'resize.throttled', { dimensions: getDimensions() });
 }, 50),

 throttledLazy: throttle(function() {
 events.emit(window, 'resize.throttled.lazy', { dimensions: getDimensions() });
 }, 150),

 debounced: debounce(function() {
 events.emit(window, 'resize.debounced', { dimensions: getDimensions() });
 }, 100),

 debouncedLazy: debounce(function() {
 events.emit(window, 'resize.debounced.lazy', { dimensions: getDimensions() });
 }, 300)
 }
}

// attach a listener to `window.onscroll`
events.on(window, 'scroll', function(e) {
 Object.keys(listeners.scroll).forEach(function(key) {
 listeners.scroll[key]();
 });
});

// attach a listener to `window.onresize`
events.on(window, 'resize', function(e) {
 Object.keys(listeners.resize).forEach(function(key) {
 listeners.resize[key]();
 });
});

This is great. We declare which methods we want called for each of the events we're interested in: 'scroll' and 'resize.' Then, to each of those events we attach a single handler that invokes all of the throttled and debounced methods we set up. As seen above, the throttled and debounced methods simply re-emit namespaced events to which you can attach any number of handlers with reckless abandon!

// elsewhere in your project, attach handlers to
// the throttled and debounced events

events.on(window, 'resize.throttled', function(e) {
 // do some work that needs to happen at regular
 // intervals during continuous resize events

 console.log(e.dimensions);
 // => { height: 640, width: 900 }
}

events.on(window, 'scroll.debounced', function(e) {
 // do some computationally expensive
 // calculation after scroll

 console.log(e.scroll);
 // => 416
}

Snippet or Standalone Module?

Despite the utility of this technique, it is a poor candidate for becoming a standalone module since it's difficult to make broad assumptions about potential use cases, the source of the throttle and debounce functions, or what event system a project uses or even which listeners a project will require. However, this is a great snippet to stash in your JavaScript utility belt.

Going beyond...

Here are some additional ideas for how you might tweak this concept to fit your use case.

1. Make the function accept a configuration object that resembles the listeners var in the above snippet.

 var listeners = { .. }; 
require('windowEvents')(listeners);

2. Allow a context other than 'window' to be used

// assume the above snippet is repurposed into a module
// called 'rescrollEvents' whose argument is the context
// in which to bind the scroll and resize events

require('rescrollEvents')(document.body);

3. Both of the previous ideas combined. Now this could potentially be reused widely without modification given the flexibility of its configuration.

var listeners = { .. };
require('rescrollEvents')(window, listeners);

What do you think? How have you solved this problem on your projects?

Solomon Hawk

Solomon is a developer in our Durham, NC, office. He focuses on JavaScript development for clients such as ID.me. He loves distilling complex challenges into simple, elegant solutions.

More articles by Solomon

Related Articles