Reactivity

Let's build a multi-component reactive web app with UIElement!

Like this to-do list app inspired by TodoMVC:

Ingredients: Individual Components

We want high quality ingredients, that meet the following criteria:

  • Each compoment should be self-contained styled and functional.
  • Each component should only update its own inner HTML elements.
  • Each component should not make any assumptions about its context, the outer HTML.
  • Parent components know about child components and can interact with their public API (get() and set() states, call methods, listen to events from them). But they should not make any assumptions about the inner structure of them.

With these design principles in mind we can begin assembling the building blocks of our app.

<input-checkbox/>

The main ingredient of any to-do list is an item with a state indicating whether the task is done or not. We use native checkboxes for this, but style them a bit differently. Use native elements whenever possible, as they have good accessibility already built-in. I omit the CSS from these examples. You may style them however you want. UIElement does nothing at all with CSS.

This is how it looks as stand-alone component:

I use light DOM for inner elements as I want to take advantage of CSS cascading and have no hassle with forms and accessibility, but you could use declarative shadow DOM as well, if you prefer. Here's the HTML:

input-checkbox.html HTML

<input-checkbox>
  <label>
    <input class="visually-hidden" type="checkbox" />
    <span>Task to be done</span>
  </label>
</input-checkbox>

And this is the JavaScript:

input-checkbox.js JS

class InputCheckbox extends UIElement {
  static observedAttributes = ['checked'];

  attributeMapping = { checked: 'boolean' };

  connectedCallback() {
    !this.has('checked') && this.set('checked', false);

    // event listener for 'change' event on input[type="checkbox"]
    this.onchange = e => {
      this.set('checked', e.target.checked);
    };

    // effect to update the checked attribute on the element
    this.effect(() => {
      const checked = this.get('checked');
      checked ? this.setAttribute('checked', '') : this.removeAttribute('checked');
    });
  }
}

As explained in the components chapter, you need to import UIElement first and register the custom element in the CustomElementRegistry with a defined tag name, 'input-checkbox' in this case.

To keep this recipe concise, I will just show the class definition.

If we don't want to use the component stand-alone and don't need to query its state with CSS or querySelector(), we could shorten the script to the very minimum:

JS

class InputCheckbox extends UIElement {

  connectedCallback() {
    this.set('checked', false);

    // event listener for 'change' event on input[type="checkbox"]
    this.onchange = e => this.set('checked', e.target.checked);
  }
}

But as we want server-side rendered HTML to set the state and be able to filter by just setting a class on a parent element, we keep the version with attribute handling.

<input-button/>

Next, we need buttons to perform various actions: to submit the form for a new to-do list item, to remove an item again or to clear all completed items. As the button itself does not know what action it shall perform, we will set 'click' event handlers later on in parent components.

A button can be enabled or disabled. Our component will just handle its disabled state set by parent components:

How it looks:

HTML:

input-button.html HTML

<input-button disabled>
    <button type="button" disabled>Clear completed</button>
  </input-button>

JavaScript:

input-button.js JS

class InputButton extends UIElement {
  static observedAttributes = ['disabled'];

  attributeMapping = { disabled: 'boolean' };

  connectedCallback() {
    !this.has('disabled') && this.set('disabled', false);

    // effect to update the disabled state
    this.effect(() => {
      const disabled = this.get('disabled');
      this.querySelector('button').disabled = disabled;
      disabled ? this.setAttribute('disabled', '') : this.removeAttribute('disabled');
    });
  }
}

The last line in the effect(), to mirror the state back to the HTML attribute, is not needed for our purposes. I decided to keep it anyway as it might be confusing in other contexts if the attribute doesn't reflect the current, but only its initial state.

But why do we need such a simple component at all? Coudn't we just use a button tag everywhere we need a button and handle its disabled state there? – Of course we could! There are two reasons for a separate component:

  • Reuse the state handling without reimplementing it.
  • Scope custom styles to our custom element, thus enabling visual consistency without setting a global class name for this purpose.

My rule of thumb is: If an element has its own state, make it a component. If is is just a reflection of other state, handle it with an effect() in the containing component. Input elements usually do have their own state, and if it's just whether their are disabled or not.

<input-text/>

To enter text for a new task, we need an input field. Again, this is mainly a wrapper around native input[type="text"] and label, but with state handling and a clear() method.

How it looks:

HTML:

input-text.html HTML

<input-text value="pre-filled value">
    <label for="my-input">Label</label>
    <input type="text" id="my-input" name="my-input" value="pre-filled value" autocomplete="off" />
  </input-text>

JavaScript:

input-text.js JS

class InputText extends UIElement {

  connectedCallback() {
    const value = this.hasAttribute('value') ? this.getAttribute('value') : '';
    this.set('value', value);

    // event listener for 'input' event on the input field
    this.oninput = e => {
      this.set('value', e.target.value);
    };
  }

  /**
   * Clear the input field
   */
  clear() {
    this.set('value', '');
    this.querySelector('input').value = '';
  }

}

If we needed client-side validation for required or maxlength, you could extend this component later on and this feature would be available everywhere. But for now, this fits our needs.

We don't want other components to set the 'value' state directly. We haven't set up an effect() to handle that, so it wouldn't work. Server-side rendered HTML may set the initial value, but then it's up to the user to enter text. Other components should not mess with user-entered text. But we provide an public clear() method to reset the value to empty.

Generally, UIElement components should interact with other UIElement components by the means of states, method calls and CustomEvents rather than attributes. This saves unneccessary DOM manipulations and type conversions from and to string.

<todo-filter/>

Finally, we want to be able to filter tasks by completion state. The control is a radio group styled as a split button:

HTML:

todo-filter.html HTML

<todo-filter value="all">
  <ol>
    <li>
      <label class="selected">
        <input class="visually-hidden" type="radio" name="todo-filter" value="all" checked />
        <span>All</span>
      </label>
    </li>
    <li>
      <label>
        <input class="visually-hidden" type="radio" name="todo-filter" value="active" />
        <span>Active</span>
      </label>
    </li>
    <li>
      <label>
        <input class="visually-hidden" type="radio" name="todo-filter" value="completed" />
        <span>Completed</span>
      </label>
    </li>
  </ol>
</todo-filter>

JavaScript:

todo-filter.js JS

class TodoFilter extends UIElement {
  static observedAttributes = ['value'];

  connectedCallback() {
    !this.has('value') && this.set('value', 'all');

    // event listener for 'change' event on input[type="radio"]
    this.onchange = e => {
      this.set('value', e.target.value);
      this.querySelectorAll('label').forEach(item => item.classList.remove('selected'));
      e.target.closest('label').classList.add('selected');
    };
  }
}

That's it! Now we've got all of our building blocks.

How to Connect the Components

<todo-list/>

We can combine input-checkbox and input-button to a to-do list:

HTML:

todo-list.html HTML

<todo-list>
  <ul>
    <li>
      <input-checkbox checked>
        <label>
          <input class="visually-hidden" type="checkbox" checked />
          <span>Build a to-do list app with UIElement</span>
        </label>
      </input-checkbox>
      <input-button>
        <button type="button"></button>
      </input-button>
    </li>
    <li>
      <input-checkbox>
        <label>
          <input class="visually-hidden" type="checkbox" />
          <span>Redesign website</span>
        </label>
      </input-checkbox>
      <input-button>
        <button type="button"></button>
      </input-button>
    </li>
  </ul>
  <template>
    <li>
      <input-checkbox>
        <label>
          <input class="visually-hidden" type="checkbox" />
          <span></span>
        </label>
      </input-checkbox>
      <input-button>
        <button></button>
      </input-button>
    </li>
  </template>
</todo-list>

We render pre-existing tasks server-side directly into the list, coming maybe from a database or another storage solution where the entries are persisted. Our Web Components will automatically recognize the pre-rendered items and bind events on them when connected.

There's a special thing in this HTML snippet: We also include a template that serves as a blueprint for new items to be added. The markup is the same, except that its empty. When we need it, our todo-list component will clone it, hydrate it with the provided task text and checked state, and append it to the list.

JavaScript:

todo-list.js JS

class TodoList extends UIElement {
  static observedAttributes = ['filter'];

  connectedCallback() {
    !this.has('filter') && this.set('filter', '');
    this.items = () => this.querySelectorAll('li');

    // wait for the next animation frame so other components are also connected
    // then communicate the count of to-do items from pre-rendered state
    requestAnimationFrame(() => {
      this.#updateCount();
    });

    // event listener for 'change' event on input[type="checkbox"]
    this.onchange = () => {
      this.#updateCount();
    };

    // event listener for 'click' event on a remove button
    this.onclick = e => {
      if (e.target.localName === 'button') {
        e.target.closest('li').remove();
        this.#updateCount();
      }
    };

    // effect to set the filter class on ul
    this.effect(() => {
      const ul = this.querySelector('ul');
      const filter = this.get('filter');
      ['active', 'completed'].includes(filter) ? ul.className = filter : ul.removeAttribute('class');
    });
  }

  /**
   * Add a new task to the to-do list
   * 
   * @param {string} task – new task to be added to the to-do list
   */
  add(task) {
    const template = this.querySelector('template');
    const clone = template.content.cloneNode(true);
    clone.querySelector('span').textContent = task;
    this.querySelector('ul').append(clone);
    this.#updateCount();
  }

  /**
   * Remove completed items from the to-do list
   */
  removeCompleted() {
    Array.from(this.items()).forEach(item => {
      item.querySelector('input-checkbox').get('checked') && item.remove();
    });
    this.#updateCount();
  }

  /**
   * Private method to update the count of all, active, and completed items
   */
  #updateCount() {
    const items = this.items();
    const all = items.length;
    const completed = Array.from(items)
      .filter(item => item.querySelector('input-checkbox').get('checked')).length;
    this.set('all', all);
    this.set('active', all - completed);
    this.set('completed', completed);
  }

}

In the connectedCallback() we set this.items as a getter for the NodeList of li elements in the list. It's a shortcut, so we don't have to repeat ourselves.

Besides that, we wait for the next animation frame before we update the count after connecting. If we wouldn't wait, the 'checked' state on input-checkbox might not yet be set up. Maybe, on a large page on a slow machine, around 17 milliseconds timeout is not enough. But that's a different issue. For now, this shall suffice to give you a hint what might go wrong if things don't work as expected after connecting the component to the DOM.

We also provide two public methods, add() and removeCompleted(), so other components can interact with the list without messing around in our inner HTML.

<todo-form/>

We're almost done. The input form still needs a submit button and we have to handle the submit event.

Our form looks like this:

HTML:

todo-form.html HTML

<form>
  <input-text>
    <label for="new-input">What needs to be done?</label>
    <input type="text" id="new-input" name="new" value="" autocomplete="off" />
  </input-text>
  <input-button type="submit" disabled>
    <button>+</button>
  </input-button>
</form>

JavaScript:

todo-form.js JS

class TodoForm extends UIElement {

  connectedCallback() {
    !this.has('new') && this.set('new', '');
    const inputField = this.querySelector('input-text');

    // event listener for 'submit' event on form
    this.onsubmit = e => {
      e.preventDefault();
      this.set('new', inputField.get('value'));
    };

    // derive disabled state of submit button from whether the input field is empty
    this.effect(() => {
      this.querySelector('input-button').set('disabled', !inputField.get('value'));
    });

    // effect to reset input field when new item has been inserted to to-do list
    this.effect(() => {
      (this.get('new') === '') && inputField.clear();
    });
  }
}

You could – and probably should – add attributes and handlers for actually sending the form to the server to persist the to-do list. But that's a different story.

Now, we are eager to finish our client-side to-do list app with UIElement.

<todo-app/>

Before the stitch all together, let's recapitulate what we've got so far. All atoms and molecules follow the same pattern:

  • A class for the component which extends UIElement
  • Almost everything we need to do, we can do in the connectedCallback() of a standard Web Component
  • We declare how attributes are mapped to states and maybe define some more
  • We add event listeners to handle user interactions, most of them just a few lines of code
  • We define effect handlers for when states change, most of them just a few lines of code
  • Sometimes, we add methods so other components can interact with our component state without touching our internals

It doesn't get more complicated to combine it all to a real application.

Here's the HTML:

todo-app.html HTML

<todo-app>
  <todo-form><!-- form markup --></todo-form>
  <todo-list><!-- list markup --></todo-list>
  <footer class="hidden">
    <p class="todo-count"></p>
    <todo-filter><!-- filter markup --></todo-filter>
    <input-button class="clear-completed" disabled>
      <button>Clear completed</button>
    </input-button>
  </footer>
</todo-app>

For this documentation site, I use Eleventy with WebC components to do the server-side rendering of the very components I describe here. This allows me to just write ...

HTML

<todo-app></todo-app>

... – and bam, the required inner markup is there. So I can just publish it as static HTML with inlined CSS and JavaScript of only the components I actually use on a page.

Thanks Zach Leatherman for helping to make HTML, CSS, JavaScript shine so bright again – without a complicated build-step, without tons of dependencies, without fancy abstraction languages in between. It's my strong believe that frontend development should be simple again. Built on rock-solid base technologies, finally mature and cross-browser interoperable in 2024, so we might no longer need anything else.

Enough advertisement, here's the final JavaScript:

todo-app.js JS

class TodoApp extends UIElement {
  
  connectedCallback() {
    
    // children
    const todoForm = this.querySelector('todo-form');
    const todoList = this.querySelector('todo-list');
    const todoFilter = this.querySelector('todo-filter');
    const clearCompleted = this.querySelector('.clear-completed');

    // event listener for 'click' event on 'Clear completed' button
    clearCompleted.onclick = () => {
      todoList.removeCompleted();
    };

    // set derived states on children
    this.effect(() => {
      todoList.set('filter', () => todoFilter.get('value'));
      clearCompleted.set('disabled', () => todoList.get('completed') === 0);
    });

    // effect to create a new to-do list item
    this.effect(() => {
      if (todoForm.get('new') !== '') {
        todoList.add(todoForm.get('new'));
        todoForm.set('new', '');
      }
    });

    // effect to show / hide footer 
    this.effect(() => {
      const footer = this.querySelector('footer');
      todoList.get('all') === 0 ? footer.classList.add('hidden') : footer.classList.remove('hidden');
    });

    // effect to set the count message
    this.effect(() => {
      const count = todoList.get('active');
      const getCountMessage = () => {
        switch (count) {
          case 0: return 'Congrats, all done!';
          case 1: return '1 item left';
          default: return count + ' items left';
        }
      };
      this.querySelector('.todo-count').textContent = getCountMessage();
    });

  }
}

This app is not fully feature-complete compared to TodoMVC implementations with JavaScript frameworks. URL handling, persistance and renaming of items are missing. I'll leave that as an exercise for you.

The code is also not optimized for this particular use-case. We could save a few bytes if we removed the attribute handling that's not really needed within our app. I wanted to demonstrate, that it's possible to split the app up into simple, atomic components and still have the reactivity in an easy-to-understand way. The class based interface of Web Components is a little bit more verbose than what you can achieve with JavaScript frameworks, but you've got minimal overhead (around 1kB) and components code of less than 4kB JavaScript. The total size is comparable to implementations in frameworks with the smallest footprint (Svelte, Preact, Solid), see this comparison by Ryan Carniato, the author of Solid.

Reactivity and Data Flow

In the next chapter, we're gonna look at the data flow in UIElement apps more closely. It's a bit different to the data flow you might be used to from JavaScript frameworks.

Read on ➔