Attributes and Properties in Custom Elements

Now that we went over some of the basic concepts of Web Components and Custom Elements, let’s push our exploration a little bit further and discuss attributes and properties.

This post discusses the Custom Elements V1 spec.

We’ll start with a few important concepts about attributes and properties in the DOM:

Properties vs Attributes

The difference between properties and attributes can be confusing. Properties are available on a DOM node when being manipulated by JavaScript:

const myElem = document.querySelector('.my-elem');

myElem.className; // className is a property

And attributes are provided in the HTML itself. Here alt, width and height are all attributes:

<img src="/path/to/img.svg" alt="My Image" width="150" height="250">

Attributes should only be used for scalar values like strings, numbers and boolean values. Properties, on the other hand, are perfectly suited to also hold values that are objects or arrays.

Reflecting Properties to Attributes

Most properties reflect their values as attributes, meaning that if the property is changed using JavaScript, the corresponding attribute is also changed at the same time to reflect the new value. This is useful for accessibility and to allow CSS selectors to work as intended.

You can try it out yourself for a concrete example. Just select, say, an image element in your browser’s developer tools, and then change one of its properties:

const fancyImage = document.querySelector('.fancy-image');

fancyImage.width = 777;

Notice how the with attribute in the DOM representation is automatically changed to the new value. The same is true if you change the value for the attribute manually in the DOM inspector, you’ll see that the property will now hold the new value.

Graphic: reflecting properties to attributesAttributesProperties

Reflecting properties to attributes in Custom Elements

Your own Custom Elements should also follow this practice of reflecting properties to attributes. Luckily, it’s quite easy to do using getters and setters.

For example, if you have a custom element that has a value property that should be reflected as an attribute, here’s how you would use a getter and a setter to get the value of the attribute when doing property access and setting the new value for the attribute when the property is changed:

get value() {
  return this.getAttribute('value');
}

set value(newValue) {
  this.setAttribute('value', newValue);
}

Or, if you have a boolean property, like, say hidden:

get hidden() {
  return this.hasAttribute('hidden');
}

set hidden(isHidden) {
  if (isHidden) {
    this.setAttribute('hidden', '');
  } else {
    this.removeAttribute('hidden');
  }
}

Listening for Changed Attributes

With Custom Elements, you can listen for attribute changes using the attributeChangedCallback method. This makes it easy to trigger actions when attributes are changed. To help with performance, only attributes defined with an observedAttributes getter that returns an array of observed attribute names will be observed.

The attributeChangedCallback is defined with three parameters, the name of the attribute, the old value and the new value. In this example, we observe the value and max attributes:

static get observedAttributes() {
  return ['value', 'max'];
}

attributeChangedCallback(name, oldValue, newValue) {
  switch (name) {
    case 'value':
      console.log(`Value changed from ${oldValue} to ${newValue}`);
      break;
    case 'max':
      console.log(`You won't max-out any time soon, with ${newValue}!`);
      break;
  }
}

Notice also that the observedAttributes getter is a static method on the class. Static methods are often used as utility methods for the class itself because they are unavailable on class instances.

Putting it All Together

Let’s put all these concepts together by building a simple counter element, similar to the one that we build using Stencil.

Our component can be used like this:

<my-counter></my-counter>

Or it can be used with the following attributes:

<my-counter value="100" step="5" max="150" min="2"></my-counter>

Here’s the full code for our custom element, with a few interesting parts highlighted:

my-counter.js

(function() {
  const template = document.createElement('template');

  template.innerHTML = `
    <style>
      button,
      span {
        font-size: 3rem;
        font-family: monospace;
        padding: 0 .5rem;
      }

      button {
        background: pink;
        color: black;
        border: 0;
        border-radius: 6px;
        box-shadow: 0 0 5px rgba(173, 61, 85, .5);
      }

      button:active {
        background: #ad3d55;
        color: white;
      }
    </style>
    <div>
      <button type="button" increment>+</button>
      <span></span>
      <button type="button" decrement>-</button>
    </div>
  `;

  class MyCounter extends HTMLElement {
    constructor() {
      super();

      this.increment = this.increment.bind(this);
      this.decrement = this.decrement.bind(this);

      this.attachShadow({ mode: 'open' });
      this.shadowRoot.appendChild(template.content.cloneNode(true));

      this.incrementBtn = this.shadowRoot.querySelector('[increment]');
      this.decrementBtn = this.shadowRoot.querySelector('[decrement]');
      this.displayVal = this.shadowRoot.querySelector('span');
    }

    connectedCallback() {
      this.incrementBtn.addEventListener('click', this.increment);
      this.decrementBtn.addEventListener('click', this.decrement);

      if (!this.hasAttribute('value')) {
        this.setAttribute('value', 1);
      }
    }

    increment() {
      // using +myVariable coerces myVariable into a number,
      // we do this because the attribute's value is received as a string
      const step = +this.step || 1;
      const newValue = +this.value + step;

      if (this.max) {
        this.value = newValue > +this.max ? +this.max : +newValue;
      } else {
        this.value = +newValue;
      }
    }

    decrement() {
      const step = +this.step || 1;
      const newValue = +this.value - step;

      if (this.min) {
        this.value = newValue <= +this.min ? +this.min : +newValue;
      } else {
        this.value = +newValue;
      }
    }

    static get observedAttributes() {
      return ['value'];
    }

    attributeChangedCallback(name, oldValue, newValue) {
      this.displayVal.innerText = this.value;
    }

    get value() {
      return this.getAttribute('value');
    }

    get step() {
      return this.getAttribute('step');
    }

    get min() {
      return this.getAttribute('min');
    }

    get max() {
      return this.getAttribute('max');
    }

    set value(newValue) {
      this.setAttribute('value', newValue);
    }

    set step(newValue) {
      this.setAttribute('step', newValue);
    }

    set min(newValue) {
      this.setAttribute('min', newValue);
    }

    set max(newValue) {
      this.setAttribute('max', newValue);
    }

    disconnectedCallback() {
      this.incrementBtn.removeEventListener('click', this.increment);
      this.decrementBtn.removeEventListener('click', this.decrement);
    }
  }

  window.customElements.define('my-counter', MyCounter);
})();

Most of the code is pretty straight-forward and uses concepts that we discussed in this article. The highlighted parts may be new however, so here are some quick explanations:

  • In the constructor we bind the this for our increment and decrement methods to the this of the class itself. Otherwise, since these two methods are used as callbacks for event handlers on button elements, the this would be the clicked button instead of the Custom Element.
  • In the connectedCallback method we set the value attribute to an initial value of 1 if value hasn’t been set by the user of the element.
  • We remove our event listeners in the class’ disconnectedCallback method.
✖ Clear

🕵 Search Results

🔎 Searching...