Using EventTarget for State Management
Tags: webdev, vanillajs, webcomponents
Published: 2021-03-20 22:59:49

`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
<!-- HTML -->
<button>Click me</button>
```

```js
// 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);
```

[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
<!-- HTML -->
<ul>
  <li>
    <button>1</button>
  </li>
  <li>
    <button>2</button>
  </li>
  <li>
    <button>3</button>
  </li>
</ul>
```

```js
// 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'
```

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 &mdash; 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 &mdash; 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**

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

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**

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

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**

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

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**

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

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

```html
<!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>
```

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 &mdash; so clone the code, fire up a server, and give it a whirl!

https://github.com/pfrueh/EventTarget