DEV Community

Cover image for How to hook into the DOM using Vanilla JavaScript!
MirAli Mobasheri
MirAli Mobasheri

Posted on

How to hook into the DOM using Vanilla JavaScript!

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));

Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

This HTML structure creates a <p> tag and three buttons. 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 states. 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 EventTargets 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);
  }
}
Enter fullscreen mode Exit fullscreen mode

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 states 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);
}
Enter fullscreen mode Exit fullscreen mode

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);
Enter fullscreen mode Exit fullscreen mode

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);
Enter fullscreen mode Exit fullscreen mode

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));

Enter fullscreen mode Exit fullscreen mode

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.)

Discussion (0)