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.