avatarDanny Moerkerke

Summary

The provided content is an in-depth guide on how to hydrate server-side rendered Web Components, focusing on strategies for when and how to make the components interactive on the client side.

Abstract

The article discusses the concept of hydration in server-side rendered Web Components, which involves attaching a Shadow root to a Custom Element and making it interactive after initial rendering. It explains the importance of hydration for enabling interactivity without re-rendering the entire component. The guide outlines different strategies for hydration, such as on load, on interaction, and on display, each with its own implications for performance and user experience. It also delves into the technical implementation of these strategies, including code examples and considerations for event handling and redispatching. The use of IntersectionObserver for on-display hydration is emphasized, along with a discussion on how to handle quick viewport scrolls to avoid unnecessary hydration.

Opinions

  • The author emphasizes the importance of hydration for Web Components, noting that Declarative Shadow DOM only attaches a Shadow root but does not make the component interactive.
  • The author suggests that choosing the right moment to hydrate a component can significantly improve performance by avoiding unnecessary hydration of components that the user may never interact with.
  • For the on-interaction strategy, the author points out the complexity of redispatching events to ensure that user interactions are properly handled after hydration.
  • The author advocates for the use of IntersectionObserver to hydrate components when they become visible in the viewport, which can lead to better performance by deferring hydration until it's needed.
  • The author provides a solution for avoiding premature hydration during quick scrolls by implementing a delay mechanism with setTimeout and clearTimeout.
  • The article concludes by mentioning the lit library's approach to server-side rendering, suggesting that there are established libraries that can help manage the hydration process.

How To Hydrate A Server-Side Rendered Web Component

An in-depth guide to lazy loading Web Components

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:

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

  1. we need to hydrate the component when it’s clicked
  2. 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:

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:

  • a callback that is invoked when the target intersects with the parent or viewport and when the observer is first asked to observe the target
  • an options object

The options object has three keys:

  • root: the ancestor element that is used as viewport for checking the visibility of the target (defaults to the browser viewport when not specified or null)
  • rootMargin: margin around the root which increases or decreases the area in which intersections will be calculated.
  • threshold: the ratio of intersection. 0 means that intersection will be calculated when only a single pixel of the target intersects with the root, 1 means the whole target needs to be visible before intersection will be calculated. This can also be an array of values. For example, to calculate intersections for every 25% of the target you need to specify: [0, 0.25, 0.5, 0.75, 1]

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 newsletter on the modern web platform, web components and Progressive Web Apps.

Web Components
JavaScript
Front End Development
Web Development
Recommended from ReadMedium