Component hydration with no dependencies

published on August 27, 2023

Client component hydration has been a trendy topic these days. Some frameworks like Next, Astro and others are already doing it.

It is not common knowledge that we can achieve a very similar result with pure SSR and declarative shadow DOM.

tldr;

Here’s what we will achieve, with examples:

  1. Create client web components that will be used to hydrate the server one
  2. Create server component to provide the structure on the client
  3. Style the web component with reusable styles

Demo first

Here are two buttons—both are created from a server rendered HTML structure that is automatically hydrated on the client with the client component. Watch the console while you click them, you’ll see a brief message logged each time you click any of them.

Primary Secondary

How?

I am using a feature called Declarative Shadow DOM to make this happen. Here’s more details on this feature from the Chrome team.

Practically, we can now define a shadow DOM that will be rendered by the browser at render and on top of that it will also automatically upgrade that to a client web component, assuming we have defined and declared that appropriately.

Client web component

In order to be able to hydrate automatically, the browser need an actual client component. Here’s how this component looks like in the case of the blue button, which I called PrimaryButton


      class PrimaryButton extends HTMLElement {
        constructor() {
          super();
          this.shadowRoot.querySelector("button").removeAttribute("disabled");
          this.shadowRoot.querySelector("button").addEventListener("click", e => {
            e.preventDefault();
            console.log("button clicked");
          });
        }
      }
      customElements.define("primary-button", PrimaryButton);
    

Not much happening here: I declare the class and in its constructor I call the super(), remove the disabled attribute and then I attach a click event for demo purposes.

And here’s the magic: when the browser is parsing the HTML and it has this client component available, it will automatically hydrate the server rendered HTML with this logic—we literally don’t need to do anything for this to happen, only have the server rendered structure in place. Let’s have a look at that.

Server component

This is nothing else but a fancy name for the html from the server, whic looks like this in HTML as a Go template:


      <primary-button>
        {{template "button" "primary"}}
        Primary
      </primary-button>
    

      {{define "button"}}
        <template shadowrootmode="open">
          <link rel="stylesheet" href="/public/css/button.css" />
          <link rel="stylesheet" href="/public/css/{{.}}-button.css" />
          <button disabled>
            <slot<>/slot>
          </button>
          </template>
      {{end}}
    

First one is the usage of the web component bound to a custom element, the second one is the reusable part for the declarative DOM shadow itself.

The second one also helps me pass in a parameter to load a different CSS specific for that particular component.

The more general button CSS:


      button {
        color: var(--color);
        background-color: var(--background-color);
        font-size: var(--font-size);
        padding: .5em 1em;
        border: 1px solid var(--color-neutral-1);
        border-radius: var(--space-l);
        cursor: pointer;
      }
      
      button[disabled] {
        opacity: .5;
        cursor: not-allowed;
      }
    

And the more specific button CSS, which just overrides the CSS vars I want to use for styling:


      button {
        --color: var(--color-neutral-1);
        --background-color: var(--color-brand);
        --font-size: var(--step-1);
      }
    

Important note: not all CSS leaks through the shadow barrier. Here’s the list of CSS properties that do:


      1. border-collapse        13. font-weight               25. text-align
      2. border-spacing         14. font-size-adjust          26. text-align-last
      3. caption-side           15. font-stretch              27. text-decoration-color
      4. color                  16. letter-spacing            28. text-indent
      5. cursor                 17. line-height               29. text-justify
      6. direction              18. list-style                30. text-shadow
      7. empty-cells            19. list-style-image          31. text-transform
      8. font                   20. list-style-position       32. white-space widows
      9. font-family            21. list-style-type           33. word-break
      10. font-size             22. orphans                   34. word-spacing
      11. font-style            23. quotes                    35. word-wrap
      12. font-variant          24. tab-size                  36. visibility
    

This is why I have used a different approach for styling the buttons as opposed to inheriting through the traditional cascade.

That is almost all

One minor challenge that I have right now is that Firefox doesn’t yet support declarative shadow DOM, so there is a shim in the source of this page to make it all work in Firefox. Hopefully they will implement soon and I can update the post.

Key takeway

We can use SSR generated web components with automatic hydration in the browsers right now, without any dependencies by implementing declarative DOM shadows and their counterpart web components. Absolutely no framework needed to achieve this.