Modern frontend rendering best-practices come at a cost. Here is a sketch of a typical "isomorphic" rendering setup with Preact. We render our <App /> component on the server and pass the generated HTML along with the data used to generate it to the client.

// server.jsx
import { h } from "preact";
import render from "preact-render-to-string";
import express from "express";
import { App } from "./app";

const server = express();

server.get("*", (req, res) => {
  const props = {
    /* ...data loaded from somewhere... */
  };
  const html = render(<App {...props} />);
  res.send(`<!DOCTYPE html>
    <html>
      <body>
        <div id="root">
          ${html}
        </div>
        <script id="__DATA__" type="application/json">
          ${JSON.stringify(props)}
        </script>
      </body>
    </html>
  `);
});

server.listen(8080);

And then, on the client, we use that data to "hydrate" our VDOM tree.

// client.jsx
import { h, hydrate } from "preact";
import { App } from "./app";

const props = JSON.parse(__DATA__.textContent);
hydrate(<App {...props} />, root);

This architecture is really nice, as it is simple to think about and allows us to share most of our code between client and server. Popular frameworks like Next.js and Gatsby implement it.

But herein lies the cost: We're sending all content to the client twice, once as HTML and then again as JSON. Furthermore, our page only becomes interactive when we're done hydrating it, and hydrating blocks the main thread—possibly for a while if our app is big or the user doesn't have a powerful CPU. Worse, in the general case, large parts of the tree that we're hydrating are static and don't update in response to state changes, meaning we wasted cycles hydrating them in the first place. "Rendering on the Web" is a good discussion of the tradeoffs involved.

Jason Miller outlines a possible alternative in "Islands Architecture":

The general idea of an “Islands” architecture is deceptively simple: render HTML pages on the server, and inject placeholders or slots around highly dynamic regions. These placeholders/slots contain the server-rendered HTML output from their corresponding widget. They denote regions that can then be "hydrated" on the client into small self-contained widgets, reusing their server-rendered initial HTML.

I want to write about one way to implement this architecture using Preact and Custom Elements that we're using on the new MAD website. Custom Elements and Web Components, the collection of standards that Custom Elements are a part of, together with Shadow DOM and HTML templates, have received their share of criticism for being an inadequate replacement for modern frontend frameworks such as React or Vue.

In my mind, their strength lies not in replacing modern frameworks, but in augmenting them. Frameworks give us a component model and rendering, while Custom Elements allow us to hook into the browser's standard element lifecycle using connectedCallback() and disconnectedCallback(). This allows us to run code in response to a piece of HTML showing up or being removed from the page—exactly what we need for granular rendering.

Let's walk through some examples of how we use this pattern on the new MAD website.

The simplest example is one where we don't use any Preact at all on the client: the lazy-image element. On the server, we wrap our <img> in a <lazy-image>, pass a transparent placeholder as the src, with the real src and srcset tucked away in dataset attributes.

<lazy-image>
  <img
    src="data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7"
    data-src="/media/1024/images/example.jpg"
    data-srcset="
      /media/512/images/example.jpg 512w,
      /media/1024/images/example.jpg 1024w,
      /media/2048/images/example.jpg 2048w,
      /media/3072/images/example.jpg 3072w,
      /media/4096/images/example.jpg 4096w
    "
    alt="An example image"
  />
</lazy-image>

And then, on the client, we attach an IntersectionObserver and switch in the real image when the element comes near the viewport.

const observer = new IntersectionObserver(
  (entries) => {
    for (const entry of entries) {
      if (entry.isIntersecting) {
        observer.unobserve(entry.target);
        entry.target.load();
      }
    }
  },
  { rootMargin: "200px" }
);

export class LazyImage extends HTMLElement {
  connectedCallback() {
    observer.observe(this);
  }

  disconnectedCallback() {
    observer.unobserve(this);
  }

  load() {
    const image = this.querySelector("img");

    if (image) {
      if (image.dataset.srcset) {
        image.srcset = image.dataset.srcset;
        delete image.dataset.srcset;
      }

      if (image.dataset.src) {
        image.src = image.dataset.src;
        delete image.dataset.src;
      }
    }
  }
}

customElements.define("lazy-image", LazyImage);

As you can see, this level of interactivity doesn't require hydrating a whole VDOM tree. We have a similar element for lazily loading videos and starting playback when they're in the viewport.

This simple example shows off one of the best things about this approach: the HTML is in control of where elements are instantiated, rather than the other way around, where a piece of JavaScript uses querySelectorAll or MutationObserver to find chunks of HTML to make interactive.

Our favourite element of this kind is <draggable-element>, which you can wrap around any element to make it draggable, and which you can see in its full glory on the home page.

<draggable-element id="applet-map">
  <MapApplet />
</draggable-element>

Let's look at an example that uses Preact on the client next, specifically the "Notes" applet on the home page.

On the server we include the data that the component needs for its initial render in a <script type="application/json"> tag.

<mad-notes>
  <NotesApplet {...props} />
  <script type="application/json" dangerouslySetInnerHTML={{ __html: JSON.stringify(props) }} />
</mad-notes>

And on the client we hydrate the component with that data.

import { h, hydrate } from "preact";
import { NotesApplet } from "../applets/notes";

class NotesElement extends HTMLElement {
  connectedCallback() {
    const props = JSON.parse(this.querySelector("script").textContent);
    hydrate(<NotesApplet {...props} />, this);
  }
}

customElements.define("mad-notes", NotesElement);

And voila, our <NotesApplet /> is now interactive.

This pattern becomes quite repetitive when you have more than a couple of elements, but you could wrap this up in a helper or use a library like preact-custom-element, which also adds a few more niceties, such as observing element attributes and syncing them with Preact component props, and optionally rendering into shadow DOM.

import register from "preact-custom-element";
import { RemindersApplet } from "../applets/reminders";

register(RemindersApplet, "mad-reminders");

We can do better, though.

While this approach allows us to only hydrate portions of the page which should be interactive, it hydrates all of them at the same time, on page load. Meanwhile, most elements are probably off-screen, and we want to prioritise those that the user will likely interact with first.

We can use an IntersectionObserver and a dynamic import to fetch the module and hydrate the component when its wrapping element comes close to the viewport.

import { h, hydrate } from "preact";

const observer = new IntersectionObserver(
  (entries) => {
    for (const entry of entries) {
      if (entry.isIntersecting) {
        oberver.unobserve(entry.target);
        entry.target.hydrate();
      }
    }
  },
  { rootMargin: "200px" }
);

class NotesElement extends HTMLElement {
  connectedCallback() {
    observer.observe(this);
  }

  disconnectedCallback() {
    observer.unobserve(this);
  }

  async hydrate() {
    const { NotesApplet } = await import("../applets/notes");
    const props = JSON.parse(this.querySelector("script").textContent);
    hydrate(<NotesApplet {...props} />, this);
  }
}

customElements.define("mad-notes", NotesElement);

Now, we only need to load a minimal amount of JavaScript in the initial bundle, and can then load the rest of it and perform hydration when it makes sense to. Depending on your situation, you could think of other approaches for triggering hydration, such as scroll listeners or mouseover or touchstart events.

You could also abstract this behaviour into a general-purpose element that can hydrate any of your Preact components, or pair this approach with a Service Worker that pre-emptively loads the code for components that are likely to be needed soon after page load, so that they're already available when the time comes to hydrate them.

And that's it! Please reach out on Twitter if you have questions or suggestions. Also, we're hiring! Check out our careers page for open positions.



Thanks to Misha Reyzlin and Rethabile Segoe for providing valuable feedback on early drafts of this article.