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:
addEventListener
removeEventListener
dispatchEvent
Node-bound EventTarget
Traditionally, the three methods above have been used directly on DOM nodes, like HTMLButtonElement
.
<!-- HTML -->
<button>Click me</button>
// 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 -->
<ul>
<li>
<button>1</button>
</li>
<li>
<button>2</button>
</li>
<li>
<button>3</button>
</li>
</ul>
// 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 — 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();
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);
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);
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);
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>
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!
Top comments (2)
Thanks for describing this simple, straightforward standards-based pattern – furthermore with clarity and precision. I believe this approach is much under appreciated.
Great article, thanks!