< Back
calendar

May 10, 2023

How To Hydrate A Server-Side Rendered Web Component

An in-depth guide to lazy loading Web Components

Header for posting How To Hydrate A Server-Side Rendered Web Component
Photo by Linus Mimietz on Unsplash

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:

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:

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>${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:

On display

The on display hydration strategy means the component will be hydrated when it becomes visible in the viewport of the user’s browser.

This doesn’t have to mean it will be hydrated immediately. For example, you probably need to make sure that a component is not hydrated when the user only quickly scrolls by it. You can also define a threshold, which means that the component will only be hydrated when a certain portion of it is visible in the viewport, expressed in pixels or a percentage.

To determine when a component becomes visible in the browser viewport we use an IntersectionObserver. This object provides a way to observe the intersection of a target element (the component) with a ancestor element or the browser viewport.

An IntersectionObserver is defined like this:

const observer = new IntersectionObserver(callback, options);

It takes two arguments:

The options object has three keys:

When intersection occurs, the callback is invoked with an array of IntersectionObserverEntry objects and the IntersectionObserver itself.

The IntersectionObserverEntry object has a number of properties that among others inform the user if the target intersects with the root, the ratio of intersection and how much of the target intersects with the root in pixels.

You can use these properties to decide if the component needs to be hydrated.

In the following example we will hydrate the component when as little as a single pixel of the component intersects with the viewport.

For the demo, we’ll wrap the <my-counter> in a <div> that can be scrolled until the component becomes visible. In real life, this will usually be the browser’s viewport.

const counter = document.querySelector('my-counter');
// the scrollable <div> around <my-counter>
const viewport = document.querySelector('.viewport');

// we specify this scrollable div as the root
const options = {
root: viewport
};

const hydrate = async (entries) => {
// iterate over the IntersectionObserver entries
for(const entry of entries) {
// hydrate the component if it intersects with the viewport
if(entry.isIntersecting) {
customElements.define('my-counter', MyCounter);
await customElements.whenDefined('my-counter');
console.log('my-counter loaded!');

// disconnect the observer as we no longer need it
observer.disconnect();
break;
}
}
};

//create the observer
const observer = new IntersectionObserver(hydrate, options);
// start observing the component
observer.observe(counter);

In the above example we create the observer and start observing the component. When the IntersectionObserverEntry records are returned we inspect the isIntersecting property of each entry to determine if the component intersects with the viewport <div>.

If it does, we hydrate the component and disconnect the IntersectionObserver since we no longer need it.

Here’s the working example:

While this works, it will also hydrate any components that a user quickly scrolls by and that is obviously not what we want. To solve this, we need to define a timer to wait some seconds before the component is hydrated. When the component is scrolled out of the viewport again the timer can be cancelled and the component will not be hydrated.

To do this, we will change the callback for the IntersectionObserver and define a separate hydrate function:

let timerId = null;
const hydrationDelay = 2000;

const callback = (entries) => {
for(const entry of entries) {
if(entry.isIntersecting) {
// if timerId is not null, a hydration has already been scheduled
if(timerId === null) {
console.log('hydration scheduled after delay');
// schedule the hydration
timerId = setTimeout(hydrate, hydrationDelay);
}
}
// target is not intersecting anymore so it was scrolled outside
// the viewport
else {
// if timerId is not null, a hydration was scheduled and that
// must now be cancelled
if(timerId !== null) {
console.log('hydration cancelled');
clearTimeout(timerId);
timerId = null;
}
}
}
};

const hydrate = async () => {
customElements.define('my-counter', MyCounter);
await customElements.whenDefined('my-counter');

console.log('my-counter loaded!');
observer.disconnect();
};

When the component intersects with the viewport <div> we use setTimeout to call the hydrate function after the delay specified by hydrationDelay. We use the id returned by setTimeout in timerId so we can determine if the hydrate function was already scheduled for execution.

If the component is scrolled out of the viewport <div> we check if a hydration was scheduled and if so, it is cancelled with clearTimeout. If, however, the component intersects with the viewport <div> for the time specified in hydrationDelay (2 seconds in this case) the component will be hydrated and the IntersectionObserver will be disconnected.

We also change the CSS of .viewport slightly so the component is now displayed in the middle, allowing us to scroll past it.

Here’s the working example:

In part 3 of this series I will explain how the lit library handles server-side rendering.


Join Modern Web Weekly, my weekly update on the modern web platform, web components, and Progressive Web Apps delivered straight to your inbox.