In this post, I want to share a simple, yet effective best practice when it comes to (JavaScript) event listeners, and code reuse.

Please bear with me while we are going through seemingly unrelated things. In the end, it will all make perfect sense. Hopefully. 😀

Event Listeners

To start simple, we first have a look at what an event listener is.

The EventListener interface represents an object that can handle an event dispatched by an EventTarget object.
MDN

OK. So event listeners take care of handling events; this is why they are also called event handlers.

An event listener in the form of a function, or other callable entity, often has a name that is a combination of the prefix on or handle, followed by the name of the individual event and the name of the target element, or vice versa. Thus, potential names are, for example, onFormSubmit and handleSubmitForm.

Adding Event Listeners

This is a simple example of adding event listeners:

window.addEventListener( 'keydown', console.log );

document.addEventListener( 'click', () => alert( 'Click!' ) );

By means of the keydown example, we can see that the first (and also only) argument passed to event listeners is the according event object.

Single Responsibility

If you know about object-oriented programming, you might have heard about the single responsibility principle; this is the “S” in SOLID. In case you do not know, here is the gist of it:

  • every class should only be responsible for a single aspect of the overall functionality;
  • there should only be a single reason to (significantly) change a class.

Single Responsibility Functions

Expanding on this idea, we might also design our functions to only do a single job. A good indicator for not doing a single job is oftentimes a Boolean flag as parameter. In a WordPress context, there are, for example, a lot of functions that, according to the bool $echo argument, either return or echo some value.

// Bad.
function answer( bool $echo = true ) {

    if ( $echo ) {
        echo 42;
    }

    return 42;
}

A much better solution is to have two separate functions, each doing only one thing, while one of them might internally make use of the other one.

// Much better.
function get_answer() {

    return 42;
}

function render_answer() {

    echo get_answer();
}

Business Logic

Every piece of software contains code that is concerned with performing one or more concrete tasks. This is what that particular code fragment is about, the business of it. This is called the business logic.

In addition to that, programs might—and oftentimes do—contain other kinds of logic such as infrastructure and data handling, or external-facing presentation.

Keep Your Business Logic Separate

One of the few rules about business logic is that you should not mix it with any of the other kinds of logic, or application tiers/layers. If the business of a function is to render data, it should not (have to) care about getting the data itself. Instead, it should require the data to be passed along.

Putting It All Together

Now, with all of the above in mind, let us have a look at a—slightly simplified—task taken from Day 1 of the free JavaScript 30 online video course by Wes Bos.

There are several HTML5 <audio> elements on the page, each being associated with a specific key on your keyboard. When you press one of the available keys, the respective audio file should get played.

Coupling Between Events and Business Logic

The naive, but also straightforward approach to this could look something like this:

window.addEventListener( 'keydown', ( e ) => {
    const key = e.keyCode;

    const audio = document.querySelector( `audio[data-key="${key}"]` );
    if ( audio ) {
        audio.currentTime = 0;
        audio.play();
    }
} );

Inside the event listener, we sort out what data we need, and then perform our business task.

Naive Duplicates

Let us now assume there was a new requirement. The page should also contain several buttons representing the wired-up keys, which means that the buttons are again associated with the keys and thus the <audio> elements. When you click one of the buttons, the according audio file should get played.

Following our naive approach from before, we might end up with code pretty similar to this:

document.querySelectorAll( 'button.key' ).forEach( ( button ) => {
    button.addEventListener( 'click', ( e ) => {
        const { key } = e.currentTarget.dataset;

        const audio = document.querySelector( `audio[data-key="${key}"]` );
        if ( audio ) {
            audio.currentTime = 0;
            audio.play();
        }
    } );
} );

You can easily see that almost all of the event listener code is just like the one from before. The only difference is in the logic to get the key code. This is what I previously called infrastructure or data handling.

Our business is that we want to play a certain audio file when either the according key gets pressed or the according button gets clicked. JavaScript is designed in a way that these input … situations—not wanting to use the word “events” here—are realized via things like Event and EventListener interfaces. To us business people, this is completely irrelevant, though.

Separating Event Handling from Business Logic

A much better way to meet the requirements is to separate performing the actual business task from the who and where and how.

For example, like so:

function playAudio( key ) {
    const audio = document.querySelector( `audio[data-key="${key}"]` );
    if ( audio ) {
        audio.currentTime = 0;
        audio.play();
    }
}

window.addEventListener( 'keydown', ( e ) => {
    playAudio( e.keyCode );
} );

document.querySelectorAll( 'button.key' ).forEach( ( button ) => {
    button.addEventListener( 'click', ( e ) => {
        playAudio( e.currentTarget.dataset.key );
    } );
} );

In the above code, the playAudio function is (the only) part of our business logic, while the two event listeners, and also the code wiring these up, are part of the infrastructure. From the individual event, we take the data we need, and then kick off the business task. This is a true separation, and you can easily see that any new event listener resulting in playing some audio file will only need a couple lines of code.

One further improvement of the second event listener would make use of event delegation, which means that we only use a single event listener instead of multiple ones. Therefore, that one event listener needs to be added higher up in the tree, and usually one goes for the document object. Thanks for mentioning this in a comment, Maximillian.

document.addEventListener( 'click', ( { target } ) => {
    if ( target.matches( 'button.key' ) ) {
        playAudio( target.dataset.key );
    }
} );

As a rule of thumb, only event listeners should ever have to be concerned with events. Well, OK, this is not entirely true, because there are, of course, also functions that create or trigger events. But no more than that.

When to Do It

Assuming we all agree that there is value in this kind of separation, there still is the question whether or not one should do this always, or only under certain circumstances (and if so, which ones). This is hard to answer, because there certainly are situations where you will never (need to) reuse whatever code is being run to handle a specific event. Obviously, however, if you do have duplicate event listener code, you really should refactor this.

Personally, I suggest to see this as a best practice, and thus always adhere to it. But that is just me, saying something now that I did not do myself a while back. 😉

Opinions?

What do you think about this?

Have you ever been in a situation where you ended up duplicating event listener code? Or do your event listeners tend to be rather bloated, because they are designed to be able to work with various kinds of events and data?

Do you think separating event handling from performing business tasks makes sense in general? Would you even do it for simple event listeners, or when you never have to reuse the code?

Please, tell me about it. I am really looking forward to reading your opinions.

7 responses to “Event Listeners and Business Logic”

  1. Thank you for your post and instruction Thorsten. I found this really informative and as a student of Zac Gordon and Wes Bos I found this compliments my study well. Looking forward to more of your posts. Thanks.

    • Hey Elliott,

      thank you for commenting.
      I wasn’t sure if I went a bit overboard with this, mentioning three unrelated things that only at the end of the post (nicely) fit together. So it’s great to know this was useful for you.

      Thanks again, and by the way, we’re fellow students. 😉

  2. Thorsten, I thought all of the pieces came together very well and especially enjoyed the modified Wes Bos example. Lately I’ve been writing ES6 classes and using CustomEvent() to trigger functions between two separate classes. One of the problems I constantly face is where to attach the event listeners. Sometimes I add the event listener in one class only to realize (like in your example) that the listener and the functionality that it triggers should not necessarily be coupled. If you need ideas for future articles, I’d really enjoy an article like this one that looks at event handling between ES6 classes. Thanks!

  3. Great post, Thorsten! I guess my only question would be — is there an inherent advantage to using Array.prototype.forEach as opposed to event delegation for adding an event listener? My thinking is that it’s more advantageous and performant to have a single event handler that responds to multiple click events on the designated event targets (or current event target rather) as opposed to several. Granted, this may not matter as much on a small scale, but for larger, more complex projects, it could make a significant difference, I’d wager.

    For example:

    document.addEventListener('click', event => {
    
        const eCurrTarget = event.currentTarget;
    
        // If anything but the intended event target is clicked on, then we hit the brakes.
    
        if (!eCurrTarget.classList.contains('.key') && eCurrTarget.tagName !== 'BUTTON'  && eCurrTarget.dataset.key === undefined) return;
    
        // Otherwise, continue! 
    
        playAudio( eCurrTarget.dataset.key );
    
    }, false);
    • Hi Maximillian,

      thanks for the reply, and a very good one at that!

      In real life, I would usually go for event delegation, true. I guess I did not do this here simply because I started in the naive example with forEach(), and then expanded on that.
      Anyway, I just updated the post and added an event delegation example using .matches() (instead of manual comparison of class and tag).

      Thanks again!

Leave a Reply

Your email address will not be published. Required fields are marked *