DEV Community

loading...

Using EventTarget for State Management

Peter Frueh
I like getting my hands dirty in the DOM.
Updated on ・4 min read

EventTarget and Node are the core interfaces of the Document Object Model (DOM). Together, these two form the foundation of low-level DOM programming. And up until relatively recently, they were bound together like two peas in a pod. To use the EventTarget interface, you could only access it via a sub-class of Node, i.e. HTMLElement. This is no longer the case! So let's dive in...

The EventTarget interface has three methods:

  1. addEventListener
  2. removeEventListener
  3. dispatchEvent

Node-bound EventTarget

Traditionally, the three methods above have been used directly on DOM nodes, like HTMLButtonElement.

<!-- HTML -->
<button>Click me</button>
Enter fullscreen mode Exit fullscreen mode
// JavaScript
const myButton = document.querySelector('button');
const myCallback = () => console.log('button was clicked');

// attach event
myButton.addEventListener('click', myCallback);

// fire event
myButton.dispatchEvent(new Event('click')); // logs 'button was clicked'

// detach event
myButton.removeEventListener('click', myCallback);
Enter fullscreen mode Exit fullscreen mode

[Note: we could also fire the event using myButton.click(), because HTMLButtonElement inherits from HTMLElement, which has a click method.]

When EventTarget is used with an HTMLElement that's connected to the DOM tree, the browser automatically propagates the event down the tree (the capture phase), to where it was fired (the target phase), and then back up again (the bubble phase). This ties the event to where it was triggered in the tree, but it also allows us to use a pattern called event delegation. In the DOM, event delegation typically refers to attaching a listener to an ancestor element to manage the interactions of its descendant elements. In the next example, the <ul> is that ancestor element, and the <button> elements are its descendants.

<!-- HTML -->
<ul>
  <li>
    <button>1</button>
  </li>
  <li>
    <button>2</button>
  </li>
  <li>
    <button>3</button>
  </li>
</ul>
Enter fullscreen mode Exit fullscreen mode
// JavaScript
const myCallback = (e) => console.log(e.target.textContent);

// attach event to the ancestor <ul>
document.querySelector('ul').addEventListener('click', myCallback);

// fire event on the second button, a descendant...
// and it will bubble up to the <ul> ancestor that handles it
document.querySelectorAll('button')[1].click(); // logs '2'
Enter fullscreen mode Exit fullscreen mode

Looking at the HTML, no matter how many items are in the list, one event handler on the <ul> will handle all of the clicks. This pattern works great in a well-structured node hierarchy — especially when the descendant nodes are dynamically generated, take very similar actions, and their numbers could be large.

Nodeless EventTarget

When using the EventTarget interface with a non-node object, we can leverage its familiar API, but stay decoupled from the DOM tree. Because of this, there isn't a standardized concept of event propagation for custom EventTarget implementations — not yet, anyway. That said, being independent of the DOM tree does open up some possibilities. And one of the most interesting ways to use EventTarget is for basic state management between non-hierarchical components.

To demonstrate, first we need to create an EventTarget singleton that can manage the data we'll be sharing, and can dispatch events when it's updated. To keep things simple, we'll use an array of strings; a to-do list.

Todos.js

class Todos extends EventTarget {
  constructor() {
    super();
    this.todos = [];
  }

  add = (todo) => {
    this.todos = [...this.todos, todo];
    this.dispatchEvent(new CustomEvent('update', { detail: this.todos }));
  };

  remove = (todo) => {
    this.todos = this.todos.filter((t) => t !== todo);
    this.dispatchEvent(new CustomEvent('update', { detail: this.todos }));
  };
}

export default new Todos();
Enter fullscreen mode Exit fullscreen mode

We now have a JavaScript module that can be imported by other components. Those components can update the data through the add and remove functions, and can also receive notifications when there's an update.

For our first component, we'll start with a custom <todo-form> element, which will give us a way to create new items. This component will talk to todos by calling add in the handleSubmit callback.

TodoForm.js

import todos from '../event-targets/Todos.js';

class TodoForm extends HTMLElement {
  constructor() {
    super();
    this.attachShadow({ mode: 'open' });
    this.shadowRoot.innerHTML = `
      <form>
        <input type="text" />
        <button type="submit">Add</button>
      </form>
    `;
  }

  connectedCallback() {
    this.shadowRoot.addEventListener('submit', this.handleSubmit);
  }

  disconnectedCallback() {
    this.shadowRoot.removeEventListener('submit', this.handleSubmit);
  }

  handleSubmit = (e) => {
    e.preventDefault();
    const input = this.shadowRoot.querySelector('input');
    if (!input.value) return;
    todos.add(input.value);
    input.value = '';
    input.focus();
  };
}

customElements.define('todo-form', TodoForm);
Enter fullscreen mode Exit fullscreen mode

Next, we'll create a <todo-list> element to display the items, and we'll place a "remove" button next to each item we show. This component will need to listen for updates, and it will also need to call remove in the handleClick callback.

TodoList.js

import todos from '../event-targets/Todos.js';

class TodoList extends HTMLElement {
  constructor() {
    super();
    this.attachShadow({ mode: 'open' });
    this.render();
  }

  connectedCallback() {
    this.shadowRoot.addEventListener('click', this.handleClick);
    todos.addEventListener('update', this.handleUpdate);
  }

  disconnectedCallback() {
    this.shadowRoot.removeEventListener('click', this.handleClick);
    todos.removeEventListener('update', this.handleUpdate);
  }

  handleClick = (e) => {
    if (e.target.nodeName.toLowerCase() === 'button') {
      const todo = e.target.closest('li').firstElementChild.textContent;
      todos.remove(todo);
    }
  };

  handleUpdate = (e) => this.render(e.detail);

  render = (todos = []) => {
    this.shadowRoot.innerHTML = `
      <ul>
        ${todos
          .map(
            (todo) => `
              <li>
                <span>${todo}</span>
                <button>remove</button>
              </li>
            `
          )
          .join('')}
      </ul>
    `;
  };
}

customElements.define('todo-list', TodoList);
Enter fullscreen mode Exit fullscreen mode

And just for good measure, we'll add a <todo-status> element that displays the total number of items. It will just need to receive notifications.

TodoStatus.js

import todos from '../event-targets/Todos.js';

class TodoStatus extends HTMLElement {
  constructor() {
    super();
    this.attachShadow({ mode: 'open' });
    this.render();
  }

  connectedCallback() {
    todos.addEventListener('update', this.handleUpdate);
  }

  disconnectedCallback() {
    todos.removeEventListener('update', this.handleUpdate);
  }

  handleUpdate = (e) => {
    const todos = e.detail;
    this.render(todos.length);
  };

  render = (numTodos = 0) => {
    this.shadowRoot.innerHTML = numTodos
      ? `You have ${numTodos} todo${numTodos > 1 ? 's' : ''} to do!`
      : 'You have no todos!';
  };
}

customElements.define('todo-status', TodoStatus);
Enter fullscreen mode Exit fullscreen mode

The custom elements above can then be placed in an HTML document — at any location within the DOM tree or component hierarchy — and they can all share a common state. Voila!

<!DOCTYPE html>
<html lang="en" dir="ltr">
  <head>
    <meta charset="utf-8" />
    <title>Todos</title>
    <script src="./custom-elements/TodoForm.js" type="module"></script>
    <script src="./custom-elements/TodoList.js" type="module"></script>
    <script src="./custom-elements/TodoStatus.js" type="module"></script>
  </head>
  <body>
    <todo-form></todo-form>
    <todo-list></todo-list>
    <todo-status></todo-status>
  </body>
</html>
Enter fullscreen mode Exit fullscreen mode

Hopefully this gives you a better understanding of what EventTarget is, and how it can be used. The JavaScript above runs natively, as-is, in all modern browsers — so clone the code, fire up a server, and give it a whirl!

https://github.com/pfrueh/EventTarget

Discussion (0)