How to hook into the DOM using Vanilla JavaScript!
MirAli Mobasheri
Posted on February 26, 2022
An element. A very simple element. It's there. Right in the DOM tree. But we want to hook into it. We want to use simple methods to control what it renders. To control when it updates.
If you're a web developer, then you might be familiar with React Hooks. I've also written articles on React Hooks Flow. But this is not about them.
Sure. There's a similarity. They're hooks in React because they let stateless functions use the Class Components abilities like states and lifecycles.
Here we're going to write logic that saves the value or the state of a DOM element and updates it as the state changes. Then this is not about React. But about an interesting way to interact with the DOM. From pure JavaScript!
What are we going to do?
Think of a simple counter app. There are a few elements on the screen to let the user interact with it.
It displays a big number. Which demonstrates the current count.
You click a button and it increments the number. Clicking another one results in decrement. The third button lets you reset the counter to zero.
We're going to create this app. But we're going to do so in a different way. First, we'll write some helper classes to allow us with hooking into the DOM. Then we're going to use them to construct the app logic.
This is how we're going to use those helper classes:
const count = new StateHook("count", 0);
new RenderHook(() => document.getElementById("counter"))
.use(count)
.modify((el) => (el.innerText = `${count.value}`));
document.getElementById("incrementBtn")
.addEventListener("click", () => count.update(count.value + 1));
document.getElementById("decrementBtn")
.addEventListener("click", () => count.update(count.value - 1));
document.getElementById("resetBtn")
.addEventListener("click", () => count.update(0));
That's it. Of course, we need to write the HTML part, which is short. And we've to create those helper objects.
This piece of code might seem strange. Even unfamiliar. And that's okay. Because we're going to understand everything step by step.
In the end, you've got a mini helper library that you can extend or use to create new projects.
If you're still in doubt whether this article is for you or not, then let me show you what topics it covers.
What aspects of JS are we going to work with?
- DOM manipulation. A very simple example of it.
- Classes in JS and their different aspects. Like the public and local properties, inheritance, and chaining.
- The EventTarget instance. This is the main part. To be able to replicate the React Hook Flow order, we have to work with events.
- Understanding how React applications look under the hood.
If these seem interesting to you, let's move along.
Creating the project
Only three files. I don't want to waste your time with npm
and CSS styling
. Create a file and name it index.html
. The two other files are scripts. We'll name them: hooks.js
and scripts.js
.
Paste the following boilerplate into index.html
:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<script src="hooks.js"></script>
<script src="scripts.js"></script>
<title>Vanilla Hooks</title>
</head>
<body>
<main>
<div id="root">
<div class="counter">
<div class="counter__number">
<p class="number" id="counter">Loading...</p>
</div>
<div class="counter__actions">
<button id="incrementBtn" class="actions__button">
+ Increment
</button>
<button id="decrementBtn" class="actions__button">
- Decrement
</button>
<button id="resetBtn" class="actions__button">
0 Reset
</button>
</div>
</div>
</div>
</main>
</body>
</html>
This HTML structure creates a <p>
tag and three button
s. The <p>
tag handles displaying the counter's current value and each of the buttons has a different role.
Now let's write some JS code.
The Hooks
We named one of the hooks files hooks.js
. This is the file where our app's core logic is going to live. We'll write some helper classes. which are able of listening to events and cause updates in the DOM
according to these events.
EventTarget
This is how the Mozilla docs explain EventTargets
in JavaScript (read more here):
The EventTarget interface is implemented by objects that can receive events and may have listeners for them. In other words, any target of events implements the three methods associated with this interface.
But why do we need to use them?
An EventTarget
interface allows us to create objects which can dispatch events. This means that in any other part of the code you can attach listeners to the events the EventTarget
dispatches.
One main parameter in handling DOM
changes is to register specific values as state
s. Whenever these values change, the hooks should reflect them in the DOM.
Then let's start with writing a state hook.
The State Hook
We aim to write a reactive interface for our application. This means that what the hooked elements render in the DOM updates in reaction to changes in our states.
We're going to use EventTarget
s to write a State class
. This class will hold the state's current value and handle its updates. When we try to change the state value, the class instance will dispatch
an update event
.
We attach an eventListener
to the state instance
. And fire callbacks when it dispatches the update event
.
Let's write the code:
class StateHook extends EventTarget {
#_value = null;
constructor(value) {
super();
this.#_value = value;
}
get value() {
return this.#_value;
}
set value(newValue) {
return null;
}
update(newValue) {
this.#_value = newValue;
const updateEvent = new CustomEvent("update");
this.dispatchEvent(updateEvent);
}
}
Let's inspect the code line by line. In the first line, we declare a JS class. We use the extends
keyword to declare that this class inherits from EventTarget
class.
This way our State Class
' instances will own the dispatchEvent
and addEventListener
methods. We can use them to handle state change events
.
In the first line inside the class we define a private instance property
named _value
. When a variable inside a class' enclosing tags starts with the #
character it becomes a private property. This means that the only way to assign its value is from inside the class enclosing tags.
This property is the one we use to store the state's latest value after each update. We defined it as a private property because we want it to be immutable like React states
.
In the next line, we write the class constructor
. It only takes one argument which we name value
. This argument is the state's initial value.
We store the initial value in the class's #_value
property.
After the constructor we define a get
and a set
method for the #_value
property. We name these methods as value
, so that's the name we'll use later to access them.
Now we can access the state value by writing instance.value
instead of instace._value
. The setter method returns null and does nothing. So that we can never write instance._value = x
. Now it's immutable.
And in the end, we define the update
method for the state
instance. This method takes an argument which we named newValue
. We assign this argument's value to the state's private 'value' property.
Then by writing const updateEvent = new CustomEvent("update")
we create a custom event with the key 'update'. Custom events are like every other event. They take a name from you, and any Event Target
can dispatch them.
In the last line of this method, we dispatch this event. Now we can attach listeners to the instances of this state. And make changes in the DOM
using the new state value.
Then let's write the second hook. Which controls what the DOM
renders, by listening to the state hook.
The Render Hook
This hook has one simple task. We give it a function by which it can find a specific element. Then we give it specific state
s which it can listen to their updates. Finally, it gets a function that we call modifier
.
It calls the modifier the first time the DOM
is ready and then each time the states' values change. It is the hook's task to keep track of the states and call the modifier when they change.
The modifier is a function that the hook calls every time the state changes. So we can use it to control what the element renders.
This is the how we can write it:
class RenderHook {
constructor(getElement) {
this._getElement = getElement;
this._modifier = null;
window.addEventListener("load", () => this.render());
}
use(state) {
state.addEventListener("update", (e) => {
this.render();
});
return this;
}
modify(modifier) {
this._modifier = modifier;
return this;
}
render() {
const theElement = this._getElement();
if (!theElement) return;
if (typeof this._modifier === "function") this._modifier(theElement);
}
RenderHook
is a simple class. It doesn't inherit from EventTarget
. Because we have no need for dispatching events from its instances.
It only takes a function as an argument and assigns its value to the _getElement
property. Calling this function should return a DOM Element
.
In the next line, we define the _modifier
property which has an initial null value. It will hold the modifier function which can be set later using a method.
At the end of the constructor, we add a listener to window's load event
. The instance's render method will run for the first time as soon as the DOM
is loaded.
After the constructor, we define a use
method. It accepts a state
argument. The argument should be an instance of the StateHook
class. Then we add a listener to its update
event. Each time a state updates it calls the instace's render
method.
At the end of this method, we return this
. You might wonder why we do so. This way we're returning the current instance. This benefits us while calling this class' methods as we can use chaining
.
Chaining is a more declarative way of calling an instance's methods. To see the difference, look at the following example. It tries to add three different states to a RenderHook
instance:
const counterRender = new RenderHook(() => document.getElementById("counter"));
counterRender.use(counterState);
counterRender.use(timeState);
counterRender.use(styleState);
The code can be shorter and more concise by using chaining. Each time we call the use
method it returns us a RenderHook
instance. So we can attach each method call to the previous one. Resulting in the following code:
new RenderHook(() => document.getElementById("counter"))
.use(counterState)
.use(timeState)
.use(styleState);
Now our code looks clean ;)
Next comes the modify method. It takes a function. And assigns it to the current instance's
_modifier
property
.
And the last method in the line is render
. It's the base of this concept. It's the promised one. The one who does the final job.
You give it no arguments. Call it and it will proceed to update the DOM
. To do so it uses what data you have provided using the other methods.
First it calls the _getElement
function. Then assigns the returned value to theElement
variable. Then it checks if theElement
is not nullish
. That can happen in case the element has been removed from the DOM
.
It calls the _modifier
function and passes theElement
to it. And the modifier can proceed to do its job. Which could be updating the DOM
.
And that's all!
How it works.
Once more let's look at the final code I showed you at the beginning:
const count = new StateHook("count", 0);
new RenderHook(() => document.getElementById("counter"))
.use(count)
.modify((el) => (el.innerText = `${count.value}`));
document.getElementById("incrementBtn")
.addEventListener("click", () => count.update(count.value + 1));
document.getElementById("decrementBtn")
.addEventListener("click", () => count.update(count.value - 1));
document.getElementById("resetBtn")
.addEventListener("click", () => count.update(0));
Now it shouldn't seem confusing anymore. We define a state using the StateHook
. Its initial value is 0. Then we create a RenderHook
. We pass it the function to get the counter text element.
We tell it to use the counter state
and start listening to its updates. And we give it a modifier
which it should call each time the counter state is updated.
In the next three lines, we use simple JavaScript. We find the button elements in the DOM
and attach listeners to them. Clicking the increment button increments the count state
's value using its update
method.
We configure the two other buttons in a similar way.
Every time we call the state's
update method it dispatches a Custom Event
. This event's name is update
. This dispatch invokes our RenderHook's
render method. And in the end, our modifier
updates the text element's innerText
.
The End.
(Cover photo by Vishal Jadhav on unsplash.)
Posted on February 26, 2022
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.