Components

UIElement components are behavior-only Web Components, that is: HTML elements enriched by JavaScript. UIElement extends HTMLElement, the base class for all HTML elements. Your custom elements need to extend UIElement:

JS

class MyCustomElement extends UIElement { /* my component definition */ }

In order make use of this newly defined class in your markup, you needs to define a tag name in the CustomElementRegistry available through the global Window.customElements:

JS

customElements.define('my-custom-element', MyCustomElement);

Valid custom element names must consist of at least two words, joined by dashes (formal definition).

Then, you can use it just like a normal HTML tag:

HTML

<my-custom-element>My light DOM content</my-custom-element>

Make sure you don't define the same tag twice, or the browser will throw an error. I usually safeguard with this helper function:

JS

/**
 * Convenience function to define a custom element if it's name is not already taken in the CustomElementRegistry
 * 
 * @param {string} tag - name of the custom element to be defined; must consist of at least two word joined with - (kebab case)
 * @param {HTMLElement} el - class of custom element; must extend HTMLElement; may be an anonymous class
 */
const define = (tag, el) => {
  !(el.prototype instanceof HTMLElement) && console.error(`Custom element class ${el.constructor.name} must extend HTMLElement`);
  try {
    customElements.get(tag) || customElements.define(tag, el);
  } catch (err) {
    console.error(err);
  }
};

This allows you to do the above steps all in one go with an anonymous class:

JS

define('my-custom-element', class extends UIElement {
  /* my component definition */
});

Web Components Lifecycle

So, you have successfully defined your custom element. Now we need to bring life (or at least client-side interactivity) to it by implementing the lifecycle callbacks of Web Components. If you're not already familiar with the concepts, I suggest you study the documentation at mdn first.

constructor()

Called when the element is created. At this point, it is not yet connected to the DOM, so you can't access attributes or inner elements. You probably don't need to implement your own constructor, but if you do, make sure you call super() first.

connectedCallback()

Called when the element is connected to the DOM. This is where you can read non-observed attributes, add event listeners, pass states to child elements and define effects to be executed when states change.

UIElement does not implement this lifecycle callback, so there's no point in calling super.connectedCallback().

disconnectedCallback()

Called when the element is disconnected from DOM. If you registered event listeners outside of your component, you need to remove those event listeners here.

UIElement does not implement this lifecycle callback, so there's no point in calling super.disconnectedCallback().

adoptedCallback()

Called when the element is moved to a new document. You probably don't need to implement this, unless you do fancy stuff like drag & drop from one document to another.

UIElement does not implement this lifecycle callback, so there's no point in calling super.adoptedCallback().

attributeChangedCallback()

Called when observed attributes are changed, added, removed, or replaced.

UIElement takes care of this lifecycle callback, so you probably don't need to override it. But if you do, make sure to call super.attributeChangedCallback() if you still want to take advantage of UIElement automatically handling attribute changes.

Read on in the next section to learn how to configure how observed attributes are parsed and mapped to states.

UIElement Properties

There is only one public property by UIElement: attributeMapping allows to declare how observed attributes are parsed and mapped to states.

UIElement.prototype.attributeMapping

The attributeMapping property of UIElement allows to configure how observed attributes are parsed and mapped to states in this element.

Type
Object
Initial value
{}

In order to invoke the attributeChangedCallback of your Web Component at all, you first have to make sure it is an observed attribute. Observed attributes are declared in the static property or accessor static observedAttributes:

JS

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

  /* The rest of my component definition */
}

Parse Primitive Types

Attributes are always strings. By default, UIElement does nothing with them and just assigns the string values to states of the same name. But you can set the expected type and UIElement will parse the attributes before assigning them to states.

Supported parsers are:

'boolean':
v => typeof v === 'string' ? true : false
'integer':
parseInt(v, 10)
'number':
parseFloat(v)

Use it like this:

JS

class MyCustomElement extends UIElement {
  static observedAttributes = ['name', 'value', 'count', 'disabled'];

  attributeMapping = {
    // name: 'string', // not neccessary as this is the default behavior
    value: 'number',
    count: 'integer',
    disabled: 'boolean',
  };

  /* The rest of my component definition */
}

Custom Parser Function

For everything else besides these primitive types you need to provide your own parser function:

JS

class MyCustomElement extends UIElement {
  static observedAttributes = ['step', 'value', 'date-updated'];

  attributeMapping = {
    step: 'number',
    value: v => Number.isInteger(this.get('step')) ? parseInt(v, 10) : parseFloat(v), 
    'date-updated': (value, old) => { /* my date parsing implementation */ },
  };

  /* The rest of my component definition */
}

The custom parser function gets two parameters: the current value and the previous value. You can access the current values of other states for conditional parsing, as you can see in the value example above. But keep in mind that they might not (yet) been set and thus return undefined.

Map to States with Different Name

If you provide an Array instead of a string or a Function as value, UIElement will assign the result of the parser function to a state with the provided name in the first item:

JS

class MyCustomElement extends UIElement {
  static observedAttributes = ['name', 'value', 'count', 'disabled'];

  attributeMapping = {
    name: ['foo'],
    value: ['bar', v => { /* my value parsing implementation */ }],
    count: ['baz', 'integer'],
    disabled: 'boolean',
  };

  connectedCallback() {
    console.log(this.has('name')); // expected output: false
    console.log(this.has('foo')); // expected output: true
    console.log(this.get('value')); // expected output: undefined
    console.log(this.get('bar')); // expected output: whatever my value parsing function returns
    console.log(typeof this.get('count')); // expected output: 'undefined'
    console.log(typeof this.get('baz')); // expected output: 'number'
    console.log(typeof this.get('disabled')); // expected output: 'boolean'
  }
}

UIElement Methods

There are five public methods implemented by UIElement. The first four implement a Map-like interface for accessing and modifying states. The last one, effect(), triggers dependent changes when states change.

UIElement.prototype.has()

The has() method of UIElement instances returns a boolean indicating whether a state with the specified key exists in this element or not.

Parameter
any key - state to be checked
Return value
boolean true if this element has state with the passed key; false otherwise

Example (assuming this is current UIElement instance):

JS

this.effect(() => {
  this.has('description') && this.querySelector('.description').textContent = this.get('description');
});

Most likely, you will need this method in effect() handlers to check whether optional states have been set. Checking has() allows you to conditionally parse other attributes, trigger DOM updates or send a request to the server.

If you just check has() in an effect() handler without actually getting the value, the effect() handler won't be executed if the state is created later on. It's meant for side constraints, not for dependencies.

UIElement.prototype.get()

The get() method of UIElement instances returns the current value of the specified state from this element.

Parameter
any key - state to get value from
Parameter
boolean raw - if true, return the reactive accessor function, otherwise return the current value; optional; default: false
Return value
boolean current value of state; undefined if state does not exist

Example (assuming this is current UIElement instance):

JS

this.effect(() => {
  this.querySelector('input').value = this.get('value');
});

Use this method to get the current value of a state. For example, in effects to update the state of a DOM element. Or to derive a computed property that depends on it.

Using get() in an effect() handler will track the state as a dependency in the reactivity map shared by all UIElement components. Changes to this property will automatically trigger the effect() handlers it is used in.

UIElement.prototype.set()

The set() method of UIElement instances creates or updates a state in this element with a specified key and a value.

Parameter
any key - state to set value to
Parameter
any value - initial or new value; may be a function (gets old value as parameter) to be evaluated when value is retrieved
Return value
void

Example (assuming this is current UIElement instance):

JS

this.querySelector('input').onchange = event => {
  this.set('value', parseInt(event.target.value, 10));
};

Use this method to update an existing state after some user interaction happened, usually in event handlers.

Most likely, you don't need to set states when observed attributes change. UIElement handles this for you according to attributeMapping.

But you can also create new states, for example when the browser sets a validation message after an input change:

JS

this.querySelector('input').onchange = event => {
    this.set('error', event.target.validationMessage);
  };

Be careful with setting states in effect() callback handlers as you could create an infinite loop. It's best practice not to do that, except to derive states that depend on other states. Make sure only one component updates the derived state in an effect, while others just read the value. If you need to reset a state after the execution of an effect to a neutral state, do it at the end of the effect callback function:

JS

this.effect(() => {
  if (this.get('newItem')) {
    // add to the item to the DOM
    this.set('newItem', '');
  }
});

Setting a state will automatically trigger all effect() handlers it is used in.

UIElement.prototype.delete()

The delete() method of UIElement instances removes the specified reactive property from this element by key.

Parameter
any key - reactive property to delete
Return value
void

Example (assuming this is current UIElement instance):

JS

this.querySelector('input').onchange = event => {
  const errorMessage = event.target.validationMessage;
  errorMessage ? this.set('error', errorMessage) : this.delete('error');
  console.log(this.has('error')); // expected output: false if event.target.validationMessage is an empty string, otherwise true
};

The delete() method completly removes a reactive property (unlike just setting it to an empty string, false or undefined). This ensures has() returns false.

Deleting a state will allow garbage collection of the state and all tracked effect() handlers it is used in.

By default, states of UIElement instances share the same lifecycle as the element itself. You don't need to delete() states in the disconnectedCallback() as you also don't need to remove event listeners on inner elements. But if you create states dynamically in event listeners or effect()s, you also have to delete() them when no longer needed to prevent memory leaks.

UIElement.prototype.effect()

The effect() method of UIElement instances adds an effect handler to the reactivity map shared by all UIElement instances. The specified callback function will be called every time when a state used in the body of the callback function changes.

Parameter
Function fn - callback function to be executed when a reactive dependency changes

Use this exported function to define what happens when states change.

Example:

JS

this.effect(() => {
  if (this.get('recalculateTotal')) {
    const total = Array.from(this.querySelectorAll('.list input'))
      .map(item => item.value)
      .reduce((acc, curr) => acc + curr);
    this.querySelector('.total').textContent = total;
    this.set('recalculateTotal', false);
  }
});

Use effect() calls whenever you want something to happen in the DOM, on the server, or with external APIs that depends on at least one state value. The execution of the effect will be deferred to the next animation frame, when the browser refreshes the view, to effienctly bundle DOM updates. The effect execution is also throttled to the next animation frame. Multiple updates of the same state will trigger only one effect execution per view refresh, using the current value of the state. As you don't know when and how many times an effect will be executed, the effect should always produce the same side effects for the same current values of states.

UIElement will trigger the effect() callback function when a used state changes. If the used state is not set when the effect is first run, the effect will run only once with an undefined value for your state and then never again, as it does not depend on any existing state. That's probably not what you intended. If you want the effect to always run, make sure you set() a default value for your state in connectedCallback(). If you want the effect to never run if the state is not set, guard it with has().

It's best to define an effect() for each independent state to avoid unneccessary DOM updates, network requests, and API calls. Derived states are not independent and may be grouped together with the original state.

Be careful with setting states in effect() callback handlers as you could create an infinite loop. It's best practice not to do that, except to derive states that depend on other states. Make sure only one component updates the derived state in an effect, while others just read the value. If you need to reset a state after the execution of an effect to a neutral state, do it at the end of an effect callback function, as shown in the example above.

How to Build Rich Applications?

Now, you know all the building blocks of a single component with UIElement. In the next section, we'll have a look how to connect them to form a rich application, where the state of a component updates automatically when something changes elsewhere, in another component.

Dive into reactivity ➔