Data Flow

Reactivity is about changing state during the lifetime of a client-side web application. There is always an origin of the change, the cause, and one or more reflective state changes in other parts of the application, the effects. In this chapter we look how information flows through multiple components from cause to effects.

Access Child Components

The most straight-forward way to pass data from one component to another is by calling set() to set a synchronized copy or derived value of a state on a child component:

todo-app.js JS

const todoList = this.querySelector('todo-list');
const todoFilter = this.querySelector('todo-filter');
const clearCompleted = this.querySelector('.clear-completed');

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

This way, a parent component, that knows about its children, can bridge states between child components that don't know each other.

But if you follow this approach over multiple levels of nested components, you end up with props drilling. The components in between might not even use those passed properties, they just pass them on. This should be avoided. Here is where Events come into play.

Bubbling Up: Events

Components that set user-entered data are usually leaf elements like input fields, button groups or lists. Their state might have an effect in one or more different parts of the application, not known to the input component. So they just send out a message to whom it may concern with their updated state: events.

For example, we could rewrite how our todo-list component informs parent elements about an updated count of all, active, and completed tasks:

todo-list.js JS

/**
 * Private method to dispatch the 'update-count' CustomEvent
 */
#updateCount() {
  const items = this.items();
  const all = items.length;
  const completed = Array.from(items)
    .filter(item => item.querySelector('input-checkbox').get('checked')).length;
  const event = new CustomEvent('update-count', {
    bubbles: true,
    detail: {
      all,
      active: all - completed,
      completed,
    },
  });
  this.dispatchEvent(event);
}

The update-count event now bubbles up until eventually an ancestor component will listen to it and cause some effect in another place. In our todo-app component:

todo-app.js JS

// event listener for 'update-count' CustomEvent from todo-list component
this.addEventListener('update-count', e => {
  const footer = this.querySelector('footer');
  e.detail.all === 0 ? footer.classList.add('hidden') : footer.classList.remove('hidden');
  this.set('active', e.detail.active);
  this.querySelector('.clear-completed').set('disabled', !e.detail.completed);
});

Like that, we don't need to be able to access the states of todo-list directly and rely on tightly coupled coordination between the two components on them. Instead, 'active' is now a state on this. With e.detail.completed we set the 'disabled' state of the "Clear completed" button directly instead of deriving it from todoList.get('completed'). And we show / hide the footer in the event listener callback instead of having a separate effect. Overall, we set less states and gain flexibility.

Getting Parent State: Context

For some use-cases, we need to get global or parent state, for example the name of the currently logged in user, the locale settings, the preferred theme, or some context for debugging or analytics. As we don't want to query the outer DOM, we need to find another way.

The emerging standard how to handle context with Web Components is the Context Community Protocol also implemented by Lit. Its a convention around a special event called context-request.

Example

We have this mini application:

Hello !

Let's see how we implement this with UIElement. As context handling implements a common protocol and involves a considerable amount of complexity, its best to separate the neccessary logic into a mixin for reuse with any context provider or consumer:

context-mixin.js JS

const isFunction = fn => typeof fn === 'function';
const contextRequest = 'context-request';

export class ContextRequestEvent extends Event {
  constructor(context, callback, subscribe) {
    super(contextRequest, {
      bubbles: true,
      cancelable: true,
      composed: true,
    });
    this.context = context;
    this.callback = callback;
    this.subscribe = subscribe;
  }
}

const ContextMixin = base => class extends base {
  static providedContexts = []; // context providers
  static observedContexts = []; // context consumers

  contextMapping = {}; // context consumers

  #contextObserverMap = new Map();
  #registeredContexts = new Map();

  connectedCallback() {

    // context providers: listen for context-request events
    this.addEventListener(contextRequest, e => {
      const { target, context, callback, subscribe } = e;
      if (!this.constructor.providedContexts.includes(context) || !isFunction(callback)) return;
      e.stopPropagation();
      const value = this.get(context);
      if (subscribe) {
        const unsubscribe = () => this.#contextObserverMap.get(context).delete(target);
        !this.#contextObserverMap.has(context) && this.#contextObserverMap.set(context, new Map());
        const contextMap = this.#contextObserverMap.get(context);
        !contextMap.has(target) && contextMap.set(target, callback);
        callback(value, unsubscribe);
      } else {
        callback(value);
      }
    });

    // context providers: set up effects for provided contexts
    this.constructor.providedContexts.forEach(context => {
      this.effect(() => {
        const value = this.get(context);
        if (this.#contextObserverMap.has(context)) {
          this.#contextObserverMap.get(context).forEach((callback, target) => callback(
            value,
            () => this.#contextObserverMap.get(context).delete(target),
          ));
        }
      });
    });

    // context consumers: register observed contexts
    requestAnimationFrame(() => {
      this.constructor.observedContexts.forEach(context => {
        const callback = (value, unsubscribe) => {
          this.#registeredContexts.set(context, unsubscribe);
          const input = this.contextMapping[context];
          const [key, fn] = Array.isArray(input) ? input : [context, input];
          this.set(key, isFunction(fn) ? fn(value) : value);
        };
        const event = new ContextRequestEvent(context, callback, true);
        this.dispatchEvent(event);
      });
    });

  }

  disconnectedCallback() {

    // context consumers: unregister observed contexts
    this.registeredContexts.forEach(unsubscribe => isFunction(unsubscribe) && unsubscribe());
  }
}

export default ContextMixin;

If we extend the features of UIElement, we need to import the mixin first and then call ContextMixin(UIElement) to extend the class of our custom elements:

user-context.js JS

class extends ContextMixin(UIElement) {
  static observedAttributes = ['logged-in', 'display-name'];
  static providedContexts = ['logged-in', 'display-name'];

  connectedCallback() {
    super.connectedCallback();

    /* my code */ 
  }
}

Because the ContextMixin implements connectedCallback() and disconnectedCallback(), we need to call super.connectedCallback(); (and super.disconnectedCallback(); if we implement that callback).

In my implementation of the ContextMixin, I tried to provide an API that closely resembles how observed attributes are handled in UIElement.

So, for context providers, we can now declare a static providedContexts array of state keys. These will also be the context keys. Make sure they are unique in the whole application, not just the context provider component. – That's all. The rest is done by the mixin.

For context consumers, we can now declare a static observedContexts array of context keys and an optional contextMapping object. contextMapping works similar to attributeMapping, except that we have no primitive type parsing. But you may map the context key to a different property key and provide a callback function for fallback values or transformations.

Here is our context provider for user-context:

user-context.js JS

class UserContext extends ContextMixin(UIElement) {
  static observedAttributes = ['logged-in', 'display-name'];
  static providedContexts = ['logged-in', 'display-name'];

  connectedCallback() {
    super.connectedCallback();

    !this.has('logged-in') && this.set('logged-in', false);
    !this.has('display-name') && this.set('display-name', undefined);

    this.addEventListener('login-user', e => {
      this.set('logged-in', e.detail.success);
      this.set('display-name', e.detail.displayName);
    });

    this.addEventListener('logout-user', () => {
      this.set('logged-in', false);
      this.set('display-name', undefined);
    });

  }
}

Next, we need a context consumer, a hello-world greeter:

hello-world.js JS

class extends ContextMixin(UIElement) {
  static observedContexts = ['display-name'];

  contextMapping = {
    'display-name': v => v || 'World',
  };

  connectedCallback() {
    super.connectedCallback();

    this.effect(() => {
      this.querySelector('span').textContent = this.get('display-name');
    });
  }
}

And finally, we need a way to change the login state and display name of the user during the lifetime of our application. Here is a login-form:

login-form.js JS

class LoginForm extends UIElement {

  connectedCallback() {
    this.set('logged-in', false);
    const form = this.querySelector('form');
    const usernameField = this.querySelector('input-text');
    const passwordField = this.querySelector('input-password');
    const logoutButton = this.querySelector('.logout');

    // event listener for 'submit' event on form
    this.onsubmit = async e => {
      e.preventDefault();
      await 1000; // do real auth work here instead
      const userData = {
        success: true,
        displayName: usernameField.get('value'),
      };
      this.set('logged-in', true);
      const event = new CustomEvent('login-user', {
        bubbles: true,
        detail: userData,
      });
      this.dispatchEvent(event);
    };

    logoutButton.onclick = () => {
      usernameField.clear();
      passwordField.clear();
      this.set('logged-in', false);
      const event = new CustomEvent('logout-user', { bubbles: true });
      this.dispatchEvent(event);
    };

    // derive disabled state of submit button from whether the input fields are empty
    this.effect(() => {
      this.querySelector('.login').set('disabled', !usernameField.get('value') || !passwordField.get('value'));
    });

    this.effect(() => {
      const loggedIn = this.get('logged-in');
      if (loggedIn) {
        form.classList.add('hidden');
        logoutButton.classList.remove('hidden');
      } else {
        form.classList.remove('hidden');
        logoutButton.classList.add('hidden');
      }
    });

  }
}

Creating Components Dynamically

By now, you know how to define components, how to connect them to form rich applications, and how to organize the flow of data with UIElement. But what if you need to insert new components on the fly?

Read on in the next chapter ➔