Making Web Component good enough

There are many discussions and articles on why WC are not good enough, what it lacks it's issues, etc.

But talking with @rossipedia about this on Remix's Discord server recently we come up with some ideas on how a more declarative version of WC could look like and how that would be useful, not only to JS frameworks like React, Vue, Svelte, etc. but even of server-side web frameworks like Rails, Laravel, etc.

Note: I'm not a framework author so I may not now all the constraints and requirements of a framework, this is just a simple idea as user of such frameworks, both JS based and server-side ones.

HTML Partials

The main thing that will really help every framework is to have a way to define HTML partials. This is a way to define a piece of HTML that can be reused in multiple places.

In a Ruby on Rails application is common to have a app/views folder with the .html.erb files (ERB is the template language of Ruby) and in this folder, you can have partials that are used in multiple views or multiple parts of a single view.

This is how an view template looks like:

<ul>
<% @users.each do |user| %>
  <%= render partial: "user_item", locals: { item: item } %>
<% end %>
</ul>

And this is how a partial can look like

<li>
  <span><%= user.name %></span>
  <p><%= user.email %></p>
</li>

How we could achieve this using plain HTML? Well we imagine a partial is a piece of HTML that can be defined in a <template> tag.

<template name="user-item">
  <li>
    <span><slot name="name"></slot></span>
    <p><slot name="email"></slot></p>
  </li>
</template>

Now we can use this partial in multiple places in our HTML

<ul>
  <user-item>
    <slot name="name">John Doe</slot>
    <slot name="email">[email protected]</slot>
  </user-item>
  <user-item>
    <slot name="name">Jane Doe</slot>
    <slot name="email">[email protected]</slot>
  </user-item>
</ul>

Now imagine a server-side framework could set a convetion, any file with _ at the beginning of the name is a partial, the rest of the name is the name of the partial.

When a view renders a partial it could look for the correct file and include it at the bottom of the HTML response.

Then the browser can render the DOM based on the partials it loaded, for any unknown partial (the template tag was not found) it could fallback to consider it a div.

Reusable Partials

The next issue we have to fix is how to reuse a partial in multiple places. If we have to include it on every HTML then we're loading the same template on every document response.

Long time ago there was an HTML import feature that was removed from the spec, but we could bring it back to solve this.

<link rel="import" href="/partials/user-item.html" />

Then our framework could change from inlining the partials in the final HTML to ad these link tags on the head of the document. Maybe even a Link header on the response itself so it can prefetch it while the HTML is being streamed.

One interesting side-effect of this is that this framework could hash the HTML of the partials and set a long cache time on the response headers, so the browser can cache our partial templates for a long time.

This will help a lot with the reusability.

Adding Interactivity

The next step is how to add interactivity to our templates, so could go from a simple template to a full component.

The simplest way could be to let templates have a script tag with JS code that can be executed when the template is loaded.

<template name="user-item">
  <script type="module">
    console.log('User item loaded');
  </script>

  <li>
    <span><slot name="name"></slot></span>
    <p><slot name="email"></slot></p>
  </li>
</template>

For every instance of our WC the script will be executed, this script could be used to add event listeners, fetch data, etc.

The WC could rely on a simple convention to initialize a component with state, if this script tag has a export default that exports a class extending HTMLElement then the WC could use this class to initialize the component.

<template name="user-item">
  <script type="module">
    export default class extends HTMLElement {
      connectedCallback() {
        console.log('User item loaded');
      }
    }
  </script>

  <li>
    <span><slot name="name"></slot></span>
    <p><slot name="email"></slot></p>
  </li>
</template>

Now, every time we render <user-item> this will instantiate the UserItem class, this class will have a reference to the instance of the <user-item> element so we can manipulate it. And this JS file could use ES Modules to import other JS files which could be re-used in multiple components.

import z from "zod";

const schema = z.object({
  name: z.string(),
  email: z.string().email(),
});

export default class extends HTMLElement {
  connectedCallback() {
    const { name, email } = schema.parse(this.dataset);
    this.querySelector('[name="name"]').textContent = name;
    this.querySelector('[name="email"]').textContent = email;
  }
}

And to avoid inlining the script tag, we could let the script load it from another file.

<link rel="modulepreload" href="/scripts/user-item.js" />
<template name="user-item">
  <script type="module" src="/scripts/user-item.js"></script>

  <li>
    <span><slot name="name"></slot></span>
    <p><slot name="email"></slot></p>
  </li>
</template>

Combining the <link rel="modulepreload"> with a long cache time on the response headers we could have a very fast loading of our components, as the HTML is of the partial is being loaded we could also load the JS file.

A framework could further optimize this to preload the JS on the HTML of the view that is going to use the partial.

<!-- On the <head> -->
<link rel="modulepreload" href="/scripts/user-item.js" />
<link rel="import" href="/partials/user-item.html" />

<!-- On the <body> -->
<user-item>
  <slot name="name">John Doe</slot>
  <slot name="email">[email protected]</slot>
</user-item>

And of course like with the templates, we could hash the JS file, if the content changes we could change the hash and the browser will fetch the new version.

Import Maps + Hashed URLs

Because the HTML of the partial needs to reference the hashed JS file, we could leverage import maps to map the URL of the partial to the hashed URL of the HTML and JS files.

{
  "imports": {
    "/partials/user-item.html": "/partials/user-item.abc123.html",
    "/scripts/user-item.js": "/scripts/user-item.abc123.js"
  }
}

This way the framework could generate the import map and include it on the response body, so the browser can fetch the correct files.

Scoped Styles

The last thing we could add to our WC is scoped styles, this is a way to define styles that only apply to the WC and not to the rest of the document.

<template name="user-item">
  <script type="module" src="/scripts/user-item.js"></script>
  <style scoped>
    li {
      border: 1px solid #ccc;
      padding: 1rem;
    }
  </style>

  <li>
    <span><slot name="name"></slot></span>
    <p><slot name="email"></slot></p>
  </li>
</template>

When a <style scoped> is found inside the template those styles will only apply to the elements inside the template.

We could also use a link tag inside the template to reference a separate CSS file.

<template name="user-item">
  <script type="module" src="/scripts/user-item.js"></script>
  <link rel="stylesheet" href="/styles/user-item.css" />

  <li>
    <span><slot name="name"></slot></span>
    <p><slot name="email"></slot></p>
  </li>
</template>

Like this other files, we could hash the CSS for better caching, and we could include it in our import map.

{
  "imports": {
    "/partials/user-item.html": "/partials/user-item.abc123.html",
    "/scripts/user-item.js": "/scripts/user-item.abc123.js",
    "/styles/user-item.css": "/styles/user-item.abc123.css"
  }
}

And the framework could add a <link rel="preload" as="style"> on the head of the document to preload the CSS file.

<!-- On the <head> -->
<link rel="preload" href="/styles/user-item.css" as="style" />
<link rel="modulepreload" href="/scripts/user-item.js" />
<link rel="import" href="/partials/user-item.html" />

<!-- On the <body> -->
<user-item>
  <slot name="name">John Doe</slot>
  <slot name="email">[email protected]</slot>
</user-item>

Conclusion

This is a very simple idea on how WC could be improved to be more declarative, see how we could go from a simple way to re-use HTML partials to a full component model for fully interactive elements with styles and JS code.

A server-side framework could leverage this to generate the HTML, CSS and JS files, hash them and include them in the response, and generate the import map to let the browser fetch the correct files.

A JS framework like React could still be used to control how we render the components on the page, so instead of using JSX to re-use components we could use the HTML partials, then the JSX can be used to render those partials.

JS libraries could be used to add more features to the components, if HTMLElement is the base class exposed by the browser, a library could extend it to include more features like state management, event handling, etc.

import { Component } from "react/wc";

export default class extends Component {
  componentDidMount() {
    // We can use React's lifecycle methods but tied to the WC lifecycle
  }
}