skip to Main Content

I am creating a simple site bundled with Vite, but with no external frameworks like React, Vue, Svelte, etc. The site has repetitive HTML elements using different data. I would like to use custom elements for these. I’m using custom elements instead of full web components because I want to be able to use a global stylesheet. The data is in an external JSON file. I’m experiencing timing problems that create difficulties getting the data to the custom elements in time for them to be configured with it.

My HTML looks like:

<!doctype html>
<html lang="en">
  <head>...</head>
  <body>
    <main>
      <my-element data-content-id="0"></my-element>
      <another-element data-content-id="1"></another-element>
      <my-element data-content-id="2"></my-element>
      <another-element data-content-id="3"></another-element>
    </main>
    <script type="module" src="/main.js"></script>
  </body>
</html>

main.js:

import MyElement from "./custom-elements/MyElement.js"
import AnotherElement from "./custom-elements/AnotherElement.js"

const init = () => {

  // load external config
  const configFile = "/config.json"

  fetch(configFile)
    .then(config => config.json())
    .then(config => {
       console.log("[Main] app initiated in main.js")
       const myElement = new MyElement(config)
       const anotherElement = new AnotherElement(config)
      }
    })
    .catch(error => console.error('Error:', error))
}

init()

MyElement.js:

class MyElement extends HTMLElement {
    constructor(config) {
        super()
        this.config = config
        console.log("constructor")

        if (this.config) {
            console.log("calling useData from constructor", this.config)
            this.useData(this.config)
        }
    }

    useData(prop) {
        console.log("useData")
        // ... logic here ...
    }

    connectedCallback() {  
        console.log("connected callback")
        if (this.config) {
            console.log("calling useData from callback", this.config)
            this.useData(this.config)
        }

        this.innerHTML = `
            <div>
                ...
            </div>
        `;
      }
}

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

export default MyElement;

When I look at the log, I’m finding that the constructor and connected callback are logged before [Main] app initiated in main.js. Then the constructor is called/logged again when the MyElement class is instantiated, this time with data, so useData runs/logs. But that this point, I’ve run the constructor twice, and I’ve lost access the custom element in the DOM to use the data.

So, to summarize, even though I’m not instantiating the class in main.js until the data has loaded, the constructor is being called twice: once when the custom element appears in the DOM (I guess, and regardless, it is before the data is fetched), and once when it is instantiated with the data (after the data is fetched). Once the data is loaded and the class is instantiated, I don’t have the access to the custom element that I had via connectedCallback.

Is there a way to use external, fetched data in custom elements and time it correctly?

UPDATE

I realized that I could:

  1. place the imported data in a global variable, and
  2. import the custom element classes after that variable is populated.

This give me a main.js that looks like this:

const init = () => {

  // external config
  const configFile = "/config.json"

  fetch(configFile)
    .then(config => config.json())
    .then(config => {
       console.log("[Main] app initiated in main.js")

       window.globalContentData = config

       import MyElement from "./custom-elements/MyElement.js"
       import AnotherElement from "./custom-elements/AnotherElement.js"
      }
    })
    .catch(error => console.error('Error:', error))
}

init()

And an adjusted MyElement.js:

MyElement.js:

class MyElement extends HTMLElement {
    constructor() {
        super()
        this.config = window.globalContentData
        console.log("constructor")

        if (this.config) {
            console.log("calling useData from constructor", this.config)
            this.useData(this.config)
        }
    }

    useData(prop) {
        console.log("useData")
        // ... logic here ...
    }

    connectedCallback() {  
        console.log("connected callback")
        if (this.config) {
            console.log("calling useData from callback", this.config)
            this.useData(this.config)
        }

        this.innerHTML = `
            <div>
                ...
            </div>
        `;
      }
}

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

export default MyElement;

This does work. That said, I’m feeling a bit dirty about polluting the global space and am wondering if there is a more elegant way.

FWIW, I’m loving the developer experience of using custom elements for this relatively simple site instead of using larger JS frameworks.

2

Answers


  1.     <main>
          <my-element data-content-id="0"></my-element>
          <another-element data-content-id="1"></another-element>
          <my-element data-content-id="2"></my-element>
          <another-element data-content-id="3"></another-element>
        </main>
        <script type="module" src="/main.js"></script>
    

    You load your script after the Web Components in DOM have been parsed.
    Thus constructor and connectedCallback (which fires on the opening tag) have already run.

    See: https://andyogo.github.io/custom-element-reactions-diagram/

    You now have a global init() which tell? your components what to do?

    Maybe reverse the logic, make the Web Component load the data (can be memoized)

    You can help us answering your question, by adding a minimal-reproducible-example StackOverflow Snippet. It will help readers execute your code with one click. And help create answers with one click.

    Login or Signup to reply.
  2. Something like this may work:

    fetch(configFile)
        .then(config => config.json())
        .then(config => {
           console.log("[Main] app initiated in main.js")
           //at this point constructor and connectedCallback have already
           //run for all 4 elements, so you'll need to add the data another way:
           document.querySelectorAll("[data-content-id]").forEach((element,i)=>{
              element.useData(config);
              //or you might be trying to do something like this:
              //const dataContentId = element.getAttribute("data-content-id");
              //element.useData(config[dataContentId]);
              //you may want to delete the data-content-id attribute
              //if you're going to re-run this code later with newly added elements
              //element.removeAttribute("data-content-id");
              //but remind yourself in the dom what these elements were:
              //element.setAttribute("data-content-id-compiled",dataContentId);
           });
          }
        })
        .catch(error => console.error('Error:', error))
    

    The docs say there’s 3 ways to create an autonomous custom element, and it appears you have a mix of 1 and 3:

    https://html.spec.whatwg.org/multipage/custom-elements.html#custom-elements-autonomous-example

    For the other way, first remove the custom elements from your html file so it looks like:

    <!doctype html>
    <html lang="en">
      <head>...</head>
      <body>
        <main>
          
        </main>
        <script type="module" src="/main.js"></script>
      </body>
    </html>
    

    Then here’s what your fetch handler might look like:

    fetch(configFile)
        .then(config => config.json())
        .then(config => {
           console.log("[Main] app initiated in main.js")
           //in case you put this code elsewhere:
           let MyElementConstructor = customElements.get("my-element");
           let AnotherElementConstructor = customElements.get("another-element");
           //then create the elements:
           const myElement1 = new MyElementConstructor(config);
           myElement1.setAttribute("data-content-id","0");
           const anotherElement1 = new AnotherElementConstructor(config);
           anotherElement1.setAttribute("data-content-id","1");
           const myElement2 = new MyElementConstructor(config);
           myElement2.setAttribute("data-content-id","2");
           const anotherElement2 = new AnotherElementConstructor(config);
           anotherElement2.setAttribute("data-content-id","3");
           //Note, at this point, these 4 elements have had their constructors run,
           //but their connectedCallbacks have not run, you must append them to
           //the dom:
           const main = document.querySelector("main");
           main.appendChild(myElement1);
           main.appendChild(anotherElement1);
           main.appendChild(myElement2);
           main.appendChild(anotherElement2);
          }
        })
        .catch(error => console.error('Error:', error))
    

    And the way i would do this in my projects would look something like:

    fetch(configFile)
        .then(config => config.json())
        .then(config => {
           console.log("[Main] app initiated in main.js")
           const main = document.querySelector("main");       
           main.insertAdjacentHTML("beforeend", `
              <my-element data-content-id="0"></my-element>
              <another-element data-content-id="1"></another-element>
              <my-element data-content-id="2"></my-element>
              <another-element data-content-id="3"></another-element>
           `); 
           byDCID("0").useData(config);
           byDCID("1").useData(config);
           byDCID("2").useData(config);
           byDCID("3").useData(config);
           function byDCID(dcid){
             return document.querySelector(`[data-content-id="${dcid}"]`);
           }
          }
        })
        .catch(error => console.error('Error:', error))
    
    Login or Signup to reply.
Please signup or login to give your own answer.
Back To Top
Search