Search code examples
javascriptjsonfetch-apiweb-componentcustom-element

Using external data in HTML custom elements


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.


Solution

  •     <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.