Build an Event Driven TodoMVC App with 8 lightweight VanillaJS Web Components

Demo

First try out the TodoMVC App to get an idea what it is capable todo: https://mits-gossau.github.io/event-driven-web-components-todomvc-app/src/
Note about Browser compatibility: This example uses the attribute "is", which is not yet supported by IOS. It is possible to simply avoid this problem by wrapping elements with web components. As can be seen at our Real World Example.

HTML Overview

Starting at the todomvc-app-template and looking at it's HTML: https://github.com/tastejs/todomvc-app-template/blob/master/index.html respectively at the extended HTML with #attr-is tags: https://github.com/mits-gossau/event-driven-web-components-todomvc-app/blob/master/src/index.html gives us some idea of which elements are going to have logic: Alt Text

  1. the "input.new-todo" field is at the top of the application and emits the event new-todo with a value for creation of a new todo item.
  2. the "section.main" holds the "input.toggle-all" and the "ul.todo-list" and must be hidden, if there are zero items. It listens to the event all-items to toggle between hidden and shown.
  3. the "input.toggle-all" is the arrow facing down on the left hand side of the "input.new-todo". It emits the event toggle-all to toggle all items to checked or unchecked (completed or incomplete). It also listens to the event all-items to toggle itself checked or unchecked.
  4. the "ul.todo-list" holds all todo items. It listens to item emitted events regarding: edit, toggle, destroy. It also listens to the events: new-todo (where it loads TodoItem.js and appends it as child), toggle-all, clear-completed. It emits the event all-items once things change and takes care of saving to the localStorage.
  5. the "footer.footer" inherits the same web component as at entry 2. "section.main" does. It must be hidden, if there are zero items. It listens to the event all-items to toggle between hidden and shown.
  6. the "span.todo-count" listens to the event all-items and counts accordingly.
  7. the "ul.filters" listens to the global hashchange event and toggles it's selected class accordingly.
  8. the "button.clear-completed" emits the event clear-completed and listens for the event all-items to toggle if it is hidden or shown.

The list above already holds a rough description of 7 out of 8 web components (element 2. and 5. share the same web component). The 8th web component is the TodoItem.js. This one gets created by the "ul.todo-list" and listens to the events toggle-all and the global hashchange. It emits toggle, destroy and edit (edit is a reuse of the new-todo web component and bubbles through the todo item web component).

Loading the Web Components

import('./es/components/TodoList.js').then(module => ['todo-list', module.default, { extends: 'ul' }])

Import a JavaScript file. Then you receive the module object on which you find the class as module.default, as long as you also export it as default.

export default class TodoList extends HTMLUListElement {

Give it a node tagName and for the usecase of #attr-is add the option telling the DOM which exact HTMLElement it shall extend. This option must correspond with the class extends HTMLElement. All the imports can be wrapped into a Promise.all and then we throw it at customElements.define. Now our seven nodes are hooked with the web component classes. The TodoItem.js is going to be loaded by the TodoList.js as soon as it is required, eg. there is an actual entry/item.

Event Driven

An event driven architecture means that these web components are only communicating through events. The only two exceptions are found at TodoList.js, when it loads the TodoItem.js and passes the value to the constructor:

this.appendChild(new TodoItem(event.detail.value))

as well as at the TodoItem.js itself, when it loads NewTodo.js for it's editing input field. Then it passes a few attributes to the web component:

<input class="edit" value="${this.value}" is="new-todo" new-todo="edit" allow-empty allow-escape>

The beauty about events is:

  • the components are independent
  • nice overview on what they listen can be found at the connectedCallback
  • FireFox DevTools let you inspect the event listeners
  • reusable web components
  • scaling to large size architecture
  • using same architecture as the DOM itself
  • no dependencies/frameworks required => pure standard
  • simple to use
  • state management is obsolete
  • super fast and greatly lightweight: Alt Text

Overview of each Web Component

1. the "input.new-todo" gets NewTodo.js applied to it.

connectedCallback () {
  this.addEventListener('blur', this.valueListener)
  this.addEventListener('keyup', this.valueListener)
}

All the logic this component holds is inside the valueListener, which basically has a few flags, some decide upon the received attributes, when to dispatch the event 'new-todo'. The 'new-todo' event can also be renamed by passing an attribute called "new-todo" and is used as such at the TodoItem.js, which makes it dispatch the event 'edit' instead of 'new-todo'.

2. the "section.main" gets NoItemsHidden.js applied to it.

connectedCallback () {
  self.addEventListener('all-items', this.allItemsListener)
}

The allItemsListener holds the logic for this web component. It sets hide=true when the allItemsListener event.detail.items.length is falsy.

3. the "input.toggle-all" gets ToggleAll.js applied to it.

connectedCallback () {
  this.addEventListener('click', this.clickListener)
  self.addEventListener('all-items', this.allItemsListener)
}

The clickListener listens to the input checkbox being clicked and emits the event 'toggle-all' with the boolean checked as event.detail. It also listens with the allItemsListener to the 'all-items' event. There it decides, if it is still checked or not.

4. the "ul.todo-list" gets TodoList.js applied to it.

connectedCallback () {
  this.addEventListener('edit', this.updateListener)
  this.addEventListener('toggle', this.updateListener)
  this.addEventListener('destroy', this.updateListener)
  self.addEventListener('new-todo', this.newTodoListener)
  self.addEventListener('toggle-all', this.toggleAllListener)
  self.addEventListener('clear-completed', this.clearCompletedListener)
  this.loadAllItems().then(() => this.dispatchAllItems())
}

It listens to the events 'edit', 'toggle', 'destroy' emitted by it's child component TodoItem.js, which indicates that something on the items list changed and shall be propagated through dispatching the event 'all-items'. The same plus a little extra logic is executed at 'toggle-all' and 'clear-completed'. It also creates new TodoItem.js on the event 'new-todo'. At the updateListener it also saves the changes to the localStorage, which gets loaded on connectedCallback.loadAllItems.

5. the "span.todo-count" gets TodoCount.js applied to it.

connectedCallback () {
  self.addEventListener('all-items', this.allItemsListener)
}

It listens to the event 'all-items' and does update it's innerHTML accordingly to the latest count.

6. the "ul.filters" gets TodoFilters.js applied to it.

connectedCallback () {
  self.addEventListener('hashchange', this.hashchangeListener)
  this.hashchangeListener()
}

It listens to the global 'hashchange' event and updates it's selected CSS according to the global route.

7. the "button.clear-completed" gets ClearCompleted.js applied to it.

connectedCallback () {
  this.addEventListener('click', this.clickListener)
  self.addEventListener('all-items', this.allItemsListener)
}

The clickListener listens to the button being clicked and emits the event 'clear-completed'. It also listens with the allItemsListener to the 'all-items' event. There it decides, if it is hidden or shown.

8. the TodoItem.js gets loaded by TodoList.js when loadAllItems loads from localStorage or at the event 'new-todo'.

connectedCallback () {
  if (this.shouldComponentRender()) this.render()
  this.hashchangeListener()
  this.addEventListener('input', this.inputListener)
  this.addEventListener('click', this.clickListener)
  this.addEventListener('dblclick', this.dblclickListener)
  this.addEventListener('edit', this.editListener)
  self.addEventListener('toggle-all', this.toggleAllListener)
  self.addEventListener('hashchange', this.hashchangeListener)
}

The event 'input' applies to the checkbox and dispatches the event 'toggle' checked. The event 'click' applies to the delete button and dispatches the event 'destroy'. The event 'dblclick' sets the class editing. The event 'edit' is dispatched and bubbles further up by it's child input field NewTodo.js. The editListener then applies the new value. The event 'toggle-all' sets itself checked or unchecked. The global event 'hashchange' decides if this todo item shall be hidden or shown. The hashchangeListener is also triggered at the connectedCallback as well as at the inputListener, when it's own checked status changes.

ShadowDOM and encapsulated CSS

You must have encountered the term ShadowDOM and it's fabulous feature to encapsulate CSS. The TodoMVC App Template already delivers a global CSS. In this spirit, it made no sense to break out CSS and encapsulate it into the web components. But I have some examples where we use this feature:

It is certainly what sets it apart of all other approaches and is worth a separate article how to newly deal with CSS in this new environment.

Conclusion

I hope this gave an overview about the https://github.com/mits-gossau/event-driven-web-components-todomvc-app and how you could build it. I believe that an event driven architecture is the future of all frontend stacks/applications as well as it is often already applied under the hood of some framework logic, which could be redundant, if you learn how to immerse into the DOM.

Related Video Tutorials

23