How To Hydrate A Server-Side Rendered Web Component
An in-depth guide to lazy loading Web Components
In part 1 of this series I explain you how to server-side render a Web Component.
Hydration
Declarative Shadow DOM enables us to attach a Shadow root to a Custom Element and fully render it without any JavaScript. This is a huge step for web components since they can now be rendered on the server. But there is a slight problem though.
Our component doesn’t do anything, it’s not interactive.
Even worse, it’s not even a Custom Element!
If you check the server-side rendered components from my previous article in the browser’s dev tools you will notice they all have a Shadow root attached to it. But when you check the CustomElementsRegistry
to find the constructor
of the element:
const el = await customElements.get('my-element');
console.log(el) // undefined 😱
you will notice that it hasn’t even been registered as a Custom Element.
This is an important fact to realise about Declarative Shadow DOM: it only attaches a Shadow root to an element.
In other words, it only takes care of rendering the HTML of the component and nothing else. The benefit of this approach is that it enables web components to be rendered very fast, but after that, they still need to be registered as Custom Elements and be made interactive.
That is what hydration does.
Hydration means registering an HTML element as a Custom Element and making it interactive. That usually means the class
definition file for the component needs to be loaded and event listeners need to be added so it can respond to user interaction like clicking for example.
Because a Custom Element can now have a Shadow root before it’s upgraded we need to check in the constructor
if it already has a Shadow root before one is attached:
class MyElement extends HTMLElement {
constructor() {
super();
// only attach a Shadow root if there isn't one already
if(!this.shadowRoot) {
const shadowRoot = this.attachShadow({mode: 'open'});
}
}
}
In browsers that support ElementInternals
we can check its shadowRoot
property to check if it already has a Shadow root:
class MyElement extends HTMLElement {
constructor() {
super();
const internals = this.attachInternals();
// only attach a Shadow root if there isn't one already
if(!internals.shadowRoot) {
const shadowRoot = this.attachShadow({mode: 'open'});
}
}
}
When to hydrate
A major benefit of server-side rendering is that we can now choose when a component is hydrated.
For example, if the user doesn’t interact with a component we may not need to hydrate it at all. Let’s say we have a search component with an input field that enables the user to search data. If the user never uses it, there is no need to hydrate it and just rendering it will be enough.
We can then choose to only hydrate the component when the user clicks inside the field and starts typing something. At that moment, the class
definition file can be loaded in the background so the component is registered as a Custom Element and it becomes interactive. When the user then clicks a button to perform the search the component should be ready.
Of course we need to make sure that at that moment the component is really active which means that the JavaScript code for the component has been downloaded and executed. If this is not the case, nothing will happen when the user performs the search which is bad user experience.
For this reason, we need to think about hydration strategies: when do we hydrate the component?
Here are a few strategies we can use:
- On load: the component is immediately hydrated when the page is loaded. This makes sure the component is active as soon as possible, but the downside is that more code will be loaded, even for components that the user may never interact with.
- On interaction: the component is hydrated when the user interacts with it, for example by clicking, focusing or when the user’s mouse hovers over it. This makes sure only components that are actually interacted with are hydrated so as less code as possible is loaded, but the downside is that this may take too long when the network is slow and the component responds too slowly to user interaction (or not at all)
- On display: the component is hydrated when it’s scrolled into the viewport and becomes visible to the user. For this an offset can be configured which means we can decide to hydrate the component already when it’s almost visible in the viewport. For example, when it’s a certain amount of pixels away from the viewport. The benefit of this is that the component will become active sooner compared to the On interaction strategy, but the downside is again that this still may take too long when the network is slow and also that components will be hydrated that the user never interacts with.
How to hydrate
Now that we have identified these strategies for hydration, let’s look at how we can implement them.
On load
For the on load strategy we don’t really need to implement anything special. After the server-side rendered component has been loaded on the client side, we simply load its definition file and we’re done:
<my-component>
<template shadowroot="open">
...
</template>
</my-component>
<script type="module>
import '/path/to/my-component.js';
</script>
On interaction
For the on interaction strategy we first need to decide which interaction (event) will cause the component to be hydrated.
The most common scenario will be when the user clicks on the component or focuses it. For this case, we will need to add an event handler to the component which takes care of the hydration. There are however a few other things we need to take into consideration here.
If we hydrate the component when the user clicks on it, this usually means the user clicked it to perform a certain action. For example, the component may contain a button that should perform some action when the user clicks it. So when the user clicks that button that means we must now do two things:
- we need to hydrate the component when it’s clicked
- since a button is clicked that normally performs a certain action, we also need to make sure that that action is performed after the component is hydrated.
In other words, we need to make sure that the click event is redispatched: it needs to be executed again afterthe component has been hydrated.
This requires some careful timing. When the component is not hydrated yet, no event handler will be attached to the component yet either, so we can only redispatch the event when the component is hydrated and the event handler is now attached to the button. If we would redispatch the event too soon (before the component is hydrated) there will be no event handler on the button yet and nothing will happen.
The event handler that takes care of hydration will look something like this:
const myComponent = document.querySelector('my-component');
myComponent.addEventListener('click', e => {
// import definition file that also registers the component
await import('/path/to/my-component.js');
// wait until the component is registered
await customElements.whenDefined('my-component');
// redispatch the click event here
});
We first import the definition file that also registers the component (it calls customElements.define
), wait until it’s registered and then redispatch the click event.
Another important consideration is which element to redispatch the event on. We would assume we can just redispatch the event on myComponent
but when this component contains a button that needs to perform an action after hydration, this won’t work. The reason for this is that there’s an event handler set on that button and when the click event is redispatched on the component itself, that event handler will not be invoked. Therefore, we need to dispatch the event on the button itself.
But this creates two problems. First, when the user clicks somewhere on the component itself and not on the button inside it, the component should be hydrated but the button’s event handler should not be invoked. But when the user does click on the button, the component should be hydrated and after that, the button’s event handler should be invoked. But if the click event is redispatched on the component itself, the button’s event handler will never be invoked.
Let’s look at a simple example of a counter component that contains a button. The counter starts counting at 1 and each time the button is clicked the number is incremented by 1:
class MyCounter extends HTMLElement {
constructor() {
super();
this.count = 1;
if(!this.shadowRoot) {
const shadowRoot = this.attachShadow({mode: 'open'});
shadowRoot.innerHTML = `
<p>Count:
<span class="output">${this.count}</span>
</p>
<button type="button">+</button>
`;
}
}
connectedCallback() {
const output = this.shadowRoot.querySelector('.output');
const button = this.shadowRoot.querySelector('button');
const updateCounter = (e) => {
this.count++;
output.textContent = this.count;
};
button.addEventListener('click', (e) => updateCounter(e));
}
}
If we now redispatch the event on the component itself it will be hydrated but the event handler of the button will not be invoked and the counter will not be incremented:
myComponent.addEventListener('click', e => {
// import definition file that also registers the component
await import('/path/to/my-component.js');
// wait until the component is registered
await customElements.whenDefined('my-component');
// redispatch the click event here, this will NOT invoke the
// event handler of the button
myComponent.dispatchEvent(e);
});
The only solution for this is to redispatch the event on the button inside the component and not on the component itself. This will make sure the component is hydrated and the event handler of the button will be invoked. We can do this by querying the Shadow root of the component:
myComponent.addEventListener('click', e => {
// import definition file that also registers the component
await import('/path/to/my-component.js');
// wait until the component is registered
await customElements.whenDefined('my-component');
// redispatch the click event on the button so the event handler
// is invoked
const button = myComponent.shadowRoot.querySelector('button');
button.dispatchEvent(e);
});
While this works, it’s not a good solution. We now need to have knowledge of the internals of the component to know which element to dispatch the event on. Also, when the component’s internal HTML changes, this may no longer work. Even worse, if the user clicked somewhere on the component and not the button the counter will now be incremented anyway.
We need a more generic way of finding out the right element to dispatch the event on and luckily there is a solution.
By default, almost each Event
bubbles up through the DOM tree. In case of a click event, this means that it will first be invoked on the actual element that was clicked on and then on all its ancestors until it reaches the window
element. Events also have a composedPath()
method which returns an array of all elements the event bubbled up through.
This also works for Custom Elements. While the target
property of the Event
will point to myComponent
in the previous example, composedPath()
will return an array of all elements inside the component’s Shadow root that the event bubbled up through. This is what we will use to redispatch the event on the right element. Instead of redispatching the event on the component, we will no redispatch the event on the first element in the array returned by composedPath()
:
myComponent.addEventListener('click', e => {
// get the actual element that was clicked on
const target = e.composedPath()[0];
// import definition file that also registers the component
await import('/path/to/my-component.js');
// wait until the component is registered
await customElements.whenDefined('my-component');
// redispatch the click event on the correct element
target.dispatchEvent(e);
});
When the user now clicks somewhere on the component but not the button the component will be hydrated but the event handler will not be invoked. Only when the user clicks on the button (or an element inside it) the event handler will be invoked. This will make sure the event is always redispatched on the correct element.
Here’s the working example: