daniel p
daniel p

Reputation: 969

How to make web component to redender specific elements upon property update

class MyElement extends HTMLElement {
    constructor() {
        super();
        // Props
        this._color = this.getAttribute("color");
        this._myArray = this.getAttribute("myArray");
        // data
        
        // Shadow DOM
        this._shadowRoot = this.attachShadow({ mode: "open" });
        this.render();
    }
    template() {
        const template = document.createElement("template");
        template.innerHTML = `
            <style>
                :host {
                    display: block;
                }
                span {color: ${this.color}}
            </style>
            <p>Notice the console displays three renders: the original, when color changes to blue after 2 secs, and when the array gets values</p>
            <p>The color is: <span>${this.color}</span></p>
            <p>The array is: ${this.myArray}</p>
            
        `;
        return template;
    }
    get color() {
        return this._color;
    }
    set color(value) {
        this._color = value;
        this.render(); 
    }
    get myArray() {
        return this._myArray;
    }
    set myArray(value) {
        this._myArray = value;
        this.render();
    }

    render() {
        // Debug only
        const props = Object.getOwnPropertyNames(this).map(prop => {
            return this[prop] 
        })
        console.log('Parent render; ', JSON.stringify(props));
        // end debug
        this._shadowRoot.innerHTML = '';
        this._shadowRoot.appendChild(this.template().content.cloneNode(true));
    }
}

window.customElements.define('my-element', MyElement);
<!DOCTYPE html>

<head>
    <script type="module" src="./src/my-element.js" type="module"></script>
    <!-- <script type="module" src="./src/child-element.js" type="module"></script> -->
</head>

<body>
    <p><span>Outside component</span> </p>
    <my-element color="green"></my-element>
    <script>
        setTimeout(() => {
            document.querySelector('my-element').color = 'blue';
            document.querySelector('my-element').myArray = [1, 2, 3];
        }, 2000);
    </script>
</body>

I have a native web component whose attributes and properties may change (using getters/setters). When they do, the whole component rerenders, including all children they may have.

I need to rerender only the elements in the template that are affected.

import {ChildElement} from './child-element.js';
class MyElement extends HTMLElement {
    constructor() {
        super();
        // Props
        this._color = this.getAttribute("color");
        this._myArray = this.getAttribute("myArray");
        // Shadow DOM
        this._shadowRoot = this.attachShadow({ mode: "open" });
        this.render();
    }
    template() {
        const template = document.createElement("template");
        template.innerHTML = `
            <style>
                span {color: ${this.color}}
            </style>
            <p>The color is: <span>${this.color}</span></p>
            <p>The array is: ${this.myArray}</p>
            <child-element></child-element>
        `;
        return template;
    }
    get color() {
        return this._color;
    }
    set color(value) {
        this._color = value;
        this.render(); // It rerenders the whole component
    }
    get myArray() {
        return this._myArray;
    }
    set myArray(value) {
        this._myArray = value;
        this.render();
    }
    render() {
        this._shadowRoot.innerHTML = '';
        this._shadowRoot.appendChild(this.template().content.cloneNode(true));
    }
}

window.customElements.define('my-element', MyElement);
window.customElements.define('child-element', ChildElement);

Because each setter calls render(), the whole component, including children unaffected by the updated property, rerenders.

Upvotes: 0

Views: 1734

Answers (2)

Yes, if you go native you have to program all reactivity yourself.
(but you are not loading any dependencies)

Not complex, Your code can be simplified;

and you probably want to introduce static get observedAttributes and the attributeChangedCallback to automatically listen for attribute changes

customElements.define('my-element', class extends HTMLElement {
  constructor() {
    super().attachShadow({ mode: "open" }).innerHTML = `
      <style id="STYLE"></style>
      <p>The color is: <span id="COLOR"/></p>
      <p>The array is: <span id="ARRAY"/></p>`;
  }
  connectedCallback() {
    // runs on the OPENING tag, attributes can be read
    this.color   = this.getAttribute("color");
    this.myArray = this.getAttribute("myArray"); // a STRING!!
  }
  get color() {
    return this._color;
  }
  set color(value) {
    this.setDOM("COLOR" , this._color = value );
    this.setDOM("STYLE" , `span { color: ${value} }`);
  }
  get myArray() {
    return this._myArray;
  }
  set myArray(value) {
    this.setDOM("ARRAY" , this._myArray = value );
  }
  setDOM(id,html){
    this.shadowRoot.getElementById(id).innerHTML = html;
  }
});
<my-element color="green" myArray="[1,2,3]"></my-element>
<my-element color="red" myArray="['foo','bar']"></my-element>

Upvotes: 2

Harshal Patil
Harshal Patil

Reputation: 20970

You are missing a certain level of abstraction. You are trying to emulate the Vue/React way of doing things where whenever the props change, the render() function is repeatedly called. But in these frameworks, render function doesn't do any DOM manipulation. It is simply working on Virtual DOM. Virtual DOM is simply a tree of JS object and thus very fast. And, that's the abstraction we are talking about.

In your case, it is best if you rely on some abstraction like LitElement or Stencil, etc. - any web component creation framework. They would take care of handling such surgical updates. It also takes care of scheduling scenarios where multiple properties are changed in a single event loop but render is still called exactly once.

Having said that, if you want to do this by yourself, one important rule: Never read or write to DOM from the web component constructor function. Access to DOM is after the connectedCallback lifecycle event.

Here is the sample that you can try and expand:

import { ChildElement } from './child-element.js';

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

    this._shadowRoot = this.attachShadow({ mode: "open" });
    this.attached = false;
  }

  connectedCallback() {

    // Check to ensure that initialization is done exactly once. 
    if (this.attached) {
      return;
    }

    // Props
    this._color = this.getAttribute("color");
    this._myArray = this.getAttribute("myArray");
    
    // Shadow DOM
    this.render();
    this.attached = true;
  }

  template() {
    const template = document.createElement("template");
    template.innerHTML = `
      <style>
        span {color: ${this.color}}
      </style>
      <p>The color is: <span id="span">${this.color}</span></p>
      <p>The array is: ${this.myArray}</p>
      <child-element></child-element>
    `;
    return template;
  }

  get color() {
    return this._color;
  }

  set color(value) {
    this._color = value;
    
    this.shadowRoot.querySelector('#span').textContent = this._color;
    this.shadowRoot.querySelector('style').textContent = `
      span { color: ${this._color}; }
    `;
  }

  get myArray() {
    return this._myArray;
  }
  set myArray(value) {
    this._myArray = value;

    this.shadowRoot.querySelector('#span').innerHTML = this._myArray;
  }
  
  render() {
    this._shadowRoot.innerHTML = '';
    this._shadowRoot.appendChild(this.template().content.cloneNode(true));
  }
}

window.customElements.define('my-element', MyElement);
window.customElements.define('child-element', ChildElement);

The above example is a classical vanilla JS with imperative way of updating the DOM. As said earlier to have reactive way of updating the DOM, you should build your own abstraction or rely on library provided abstraction.

Upvotes: 0

Related Questions