I am making a custom web component using vanilla JavaScript and am having some issues with when the component should be rendered.
At first I had my render()
and hydrate()
calls inside the constructor of the component. This worked well when the component was already part of the DOM to begin with, however, if the component was created using document.createElement("my-button")
, then the render()
call would end up executing before I would have the chance to add attributes and child elements ect. which was a major problem.
The other alternative, which is what my code below shows, is it have the render()
and hydrate()
calls inside the connectedCallback()
method. This fixes the document.createElement("my-button")
problem, however, it introduces a new one. Because the connectedCallback()
only executes after the element is added to the DOM, I could potentially get a FOUC (Flash of unstyled content) before the component is finished rendering. It would've been nice if there was a beforeConnectedCallback()
so I can execute the code before it is added to the DOM, but this does not seem to exist.
So what should I do to get my component to automatically render and hydrate before it is added to the DOM?
Here is my component:
class MYButton extends HTMLElement {
constructor() {
super();
}
connectedCallback() {
this.render();
this.hydrate();
}
render() {
let type = this.getAttribute("type");
if (type === "link") {
this.elmButton = document.createElement("a");
this.elmButton.setAttribute("href", this.getAttribute("href"));
} else {
this.elmButton = document.createElement("button");
this.elmButton.setAttribute("type", type);
}
while (this.firstChild) {
this.elmButton.appendChild(this.firstChild);
}
this.appendChild(this.elmButton);
}
hydrate() {
this.elmButton.addEventListener("click", () => alert("hello world"));
}
}
customElements.define('my-button', MYButton);
+1 for your constructor
conclusion, you can't add DOM there
8 out of 10 developers get this wrong... because they define their components with async
or type=module
after all DOM is parsed
-1 for your connectedCallback
conclusion, it does NOT fire after DOM is created.
The
connectedCallback
fires on the opening tag<my-button>
lightDOM is not parsed yet.
For long read see my blogpost:
Web Component developers do not connect with the connectedCallback (yet)
The challenge with your Web Component is, you both want to create new lightDOM (your button
code) AND read existing lightDOM (your firstChild
)
Your create after read is perfectly fine, otherwise you would be endlessy adding that new button.
But there will always be a FOUC/Layout Shift, since you can't have the cake and eat it...
if you want that (unstyled) lightDOM you will have to wait for it.
But you can have the Web Component be hidden
and display it when all work is done,
or add a fancy opacity transition yourself.
Without the render
and hydrate
mumbo-jumbo, the Web Component is:
const createElement =
(tag, props = {}) => Object.assign(document.createElement(tag), props);
customElements.define("my-button", class extends HTMLElement {
connectedCallback() {
this.setAttribute("hidden", true);
setTimeout(() => { // wait for lightDOM to be parsed
let type = this.getAttribute("type");
let [el, attr] = (type == "link") ? ["a", "href"] : ["button", "type"];
let button = createElement(el, {
[attr]: this.getAttribute(attr),
onclick: (evt) => alert(`Web Component ${type||"button"} clicked`)
});
// .children will not include textNodes, all Nodes are *moved*
button.append(...this.childNodes);
// lightDOM is empty now
this.append(button);
this.removeAttribute("hidden");
});
}
});
<style>
[type="link"] {
display: inline-block;
background: pink;
padding: 1em;
}
</style>
<my-button>
I am the button Label
<div style="font-size:60px">😃</div>
</my-button>
<my-button type="link">
Another label
<div style="font-size:40px">🔗</div>
</my-button>
You do not need that setTimeout
when you stuff all data in attributes instead of lightDOM.
The connectedCallback
fires on the opening tag... so can access all attributes
ShadowDOM and <slot>
might give a better UI experience, but requires more code.
CSS :not(:defined)
can help, but you are now relying on dependencies outside the Web Component
you could also do this.replaceWith(button)
at the end, if your Web Component was only about creating that link/button
Makes for some cleaner HTML
If you really want to execute after the constructor
and before the connectedCallback
you can ABUSE the attributeChangedCallback
because that will fire for every declared ObservedAttribute
before the connectedCallback
fires on the <my-button>
opening tag.
BUT! The lightDOM inside your <my-button>
still was not parsed yet.
There are "gurus" out there that tell you to "solve" this with defer
, async
, import
or type="module"
They have no clue what is actually happening... all they do is delay execution of the whole JavaScript file
The other answer doesn't work always. You can't access attributes in the constructor
when the DOM wasn't parsed.
Here is an MVP showing the fault in the other answer: https://jsfiddle.net/WebComponents/970xbetz/