DEV Community

Philipp Nowinski 🎸
Philipp Nowinski 🎸

Posted on • Originally published at pno.dev

A simple pattern for creating JavaScript UI modules

During the last 6 years or so, I spent a lot of time writing small JavaScript modules to control UI components. The kind of elements you would find on most modern webpages, like tabs, accordions, image galleries – you name it.

A common mistake I've seen (and made myself) over and over again, is the weak handling of DOM references in your component's JavaScript.

One object to rule them all – or: let there be chaos

This is a pattern I found in lots of legacy codebases I got handed during my last job (and again, I'm also guilty of writing code like this in the past). Consider the following markup for a simple UI element, like an accordion:

<div class="m-accordion" id="m-accoridon-1">
    <h2 class="m-accordion__header" id="m-accordion-header-1">
        <button class="m-accordion__toggle" type="button" data-toggle="collapse" data-target="#accordion-panel-1" aria-expanded="true" aria-controls="accordion-panel-1">
            First item
        </button>
    </h2>

    <div id="accordion-panel-1" class="m-accordion__panel" aria-labelledby="m-accordion-header-1" data-parent="#m-accoridon-1">
        <!--    some accorion content    -->
    </div>
</div>

Now, as you can see, there are a few elements here we need references to. We need at least references to the accordion toggles (to add click-event-listeners) and the associated panels (to show or hide them on click). Here is one approach to handle this:

const toggles = document.querySelectorAll('.m-accordion__header');
const panels = document.querySelectorAll('.m-accordion__panel');

toggles.forEach(toggle => {
    toggle.addEventListener('click', openPanel);
});

[]

In this approach, we store all toggles and panels on the page in a constant. This works well in a reduced use case. Let's say we have multiple instances of this element on the same page. The code would still work, but things are already harder to keep track of.

So, let's add another level of complexity. Let's say we want to set different options for the accordion via data-attributes. Now you have to keep track of all .m-accordion elements, store their different option sets, and even worse: map all of those options to the toggles and panels.

This gets out of hand very quickly. So let's try to find a better approach.

One module per element

Modules are a great way to bring order into this kind of chaos. Let's try to organize our code in a way that one instance of a module is responsible for one instance of an element only.

The main cause of pain in the first example was that for each interaction (user clicks on a toggle), we needed to figure out which accordion the user was interacting with. So let's try to abstract that away. Ideally, we don't even want to be bothered with the possibility that there are other instances on the same page.

First, let's move our code into a class. If you are familiar with languages like PHP or Java, this will already be second nature for you (paradoxically though, I've seen especially developers with a strong focus on these languages struggling with applying the same paradigm to JavaScript).

The idea is simple: we will have on instance of the class for every element on the page.

class Accordion {

    constructor(_root) {
        this._root = _root;
        this._toggles = this._root.querySelectorAll('.m-accordion__header');
        this._panels = this._root.querySelectorAll('.m-accordion__panel');
        this._setupEventListeners();
    }

    _setupEventListeners() {
        this._toggles.forEach(toggle => {
            toggle.addEventListener('click', this.open.bind(this));
        });
    }

}
// instantiation of all accordion elements
const accordionElements = document.querySelectorAll('.m-accordion');
accordionElements.forEach(accordionElement => {
    new Accordion(accordionElement);
});

So, what exactly did we do here?

Let's focus on the second part first. We use document.querySelectorAll to fetch every accordion container-element from the current page. So this gives us a NodeList containing all DOMNodes that are accordion-containers. We then iterate over those elements, instantiate a new Accordion class instance and pass the accordion-container as an argument.

Then, in the class itself, we receive this element as _root in the constructor and save it as a class-variable this._root.

And that's it. From now on, we will never have to refer to document inside our class again to fetch elements that are part of the accordion. Every time we need to grab a part of our component, we can just fire querySelector on this._root.

This essentially means that all queries inside our component are scoped now. At this point, we don't have to think about other accordion instances on the same page anymore. There is no way that two instances of the accordion interfere with each other, as long as we don't break out of this scope. If we want to fetch the aforementioned configuration options now, we just grab them from this._root and be done with it.

Thinking in this kind of model makes writing UI code a lot easier and helps to keep an overview of all the moving parts of your website. It definitely helped me a bunch over the years.

Top comments (0)