https://jakelazaroff.com/words/web-components-eliminate-javascript-framework-lock-in/
Jake Lazaroff
* Blog
* Projects
* About
* Mastodon
* Twitter
* GitHub
Web Components Eliminate JavaScript Framework Lock-in
November 27, 2023
---------------------------------------------------------------------
* #javascript
* #react
* #solid
* #svelte
Table of Contents
What's a Web Component?
Scaffolding Layout
Adding Todos
Todo Items
Filtering Todos
Moving Forward
Reading List
We've seen a lot of great posts about web components lately. Many
have focused on the burgeoning HTML web components [photo-300] HTML
web components Don't replace. Augment. [icon] adactio.com/journal/
20618 pattern, which eschews shadow DOM in favor of progressively
enhancing existing markup. There's also been discussion -- including
this post by yours truly [web-compon] Web Components Will Outlive
Your JavaScript Framework | jakelazaroff.com If we're building things
that we want to work in five or ten or even 20 years, we need to
avoid dependencies and use the web with no layers in between.
[favicon] jakelazaroff.com/words/
web-components-will-outlive-your-javascript-framework/ -- about fully
replacing JavaScript frameworks with web components.
Those aren't the only options, though. You can also use web
components in tandem with JavaScript frameworks. To that end, I want
to talk about a key benefit that I haven't seen mentioned as much:
web components can dramatically loosen the coupling of JavaScript
frameworks.
To prove it, we're going to do something kinda crazy: build an app
where every single component is written with a different framework.
It probably goes without saying that you should not build a real app
like this! But there are valid reasons for mixing frameworks. Maybe
you're gradually migrating from React to Vue. Maybe your app is built
with Solid, but you want to use a third-party library that only
exists as an Angular component. Maybe you want to use Svelte for a
few "islands of interactivity" in an otherwise static website.
Here's what we're going to create: a simple little todo app based
loosely on TodoMVC [screenshot] TodoMVC Helping you select an MV*
framework - Todo apps for Backbone.js, Ember.js, AngularJS, Spine and
many more [favicon] todomvc.com .
JavaScript is required to run this demo.
As we build it, we'll see how web components can encapsulate
JavaScript frameworks, allowing us to use them without imposing
broader constraints on the rest of the application.
What's a Web Component?
In case you're not familiar with web components, here's a brief
primer on how they work.
First, we declare a subclass of HTMLElement in JavaScript. Let's call
it MyComponent:
class MyComponent extends HTMLElement {
constructor() {
super();
this.shadow = this.attachShadow({ mode: "open" });
}
connectedCallback() {
this.shadow.innerHTML = `
Hello from a web component!
`;
}
}
That call to attachShadow in the constructor makes our component use
the shadow DOM [mdn-social] Using shadow DOM - Web APIs | MDN An
important aspect of custom elements is encapsulation, because a
custom element, by definition, is a piece of reusable functionality:
it might be dropped into any web page and be expected to work. So
it's important that code running in the page should not be able to
accidentally break a custom element by modifying its internal
implementation. Shadow DOM enables you to attach a DOM tree to an
element, and have the internals of this tree hidden from JavaScript
and CSS running in the page. [favicon-48] developer.mozilla.org/en-US
/docs/Web/Web_Components/Using_shadow_DOM , which encapsulates the
markup and styles inside our component from the rest of the page.
connectedCallback is called when the web component is actually
connected to the DOM tree, rendering the HTML contents into the
component's "shadow root".
This foreshadows how we'll make our frameworks work with web
components.^1 We normally "attach" frameworks to a DOM element, and
let the framework take over all ancestors of that element. With web
components, we can attach the framework to the shadow root, which
ensures that it can only access the component's "shadow tree".
Next, we define a custom element name for our MyComponent class:
customElements.define("my-component", MyComponent);
Whenever a tag with that custom element name appears on the page, the
corresponding DOM node is actually an instance of MyComponent!
Check it out:
JavaScript is required to run this demo.
There's more to web components, but that's enough to get you through
the rest of the article.
Scaffolding Layout
The entrypoint of our app will be a React component.^2 Here's our
humble start:
// TodoApp.jsx
export default function TodoApp() {
return <>>;
}
We could start adding elements here to block out the basic DOM
structure, but I want to write another component for that to show how
we can nest web components in the same way we nest framework
components.
Most frameworks support composition via nesting like normal HTML
elements. From the outside, it usually looks something like this:
On the inside, there are a few ways that frameworks handle this. For
example, React and Solid give you access to those children as a
special children prop:
function Card(props) {
return
{props.children}
;
}
With web components that use shadow DOM, we can do the same thing
using the element [mdn-social] : The Web Component Slot
element - HTML: HyperText Markup Language | MDN The HTML
element--part of the Web Components technology suite--is a placeholder
inside a web component that you can fill with your own markup, which
lets you create separate DOM trees and present them together.
[favicon-48] developer.mozilla.org/en-US/docs/Web/HTML/Element/slot .
When the browser encounters a , it replaces it with the
children of the web component.
is actually more powerful than React or Solid's children. If
we give each slot a name attribute, a web component can have multiple
s, and we can determine where each nested element goes by
giving it a slot attribute matching the 's name.
Let's see what this looks like in practice. We'll write our layout
component using Solid [og] SolidJS Solid is a purely reactive
library. It was designed from the ground up with a reactive core.
It's influenced by reactive principles developed by previous
libraries. [favicon-32] www.solidjs.com :
// TodoLayout.jsx
import { render } from "solid-js/web";
function TodoLayout() {
return (
);
}
customElements.define(
"todo-layout",
class extends HTMLElement {
constructor() {
super();
this.shadow = this.attachShadow({ mode: "open" });
}
connectedCallback() {
render(() => , this.shadow);
}
}
);
There are two parts to our Solid web component: the web component
wrapper at the top, and the actual Solid component at the bottom.
The most important thing to notice about the Solid component is that
we're using named s instead of the children prop. Whereas
children is handled by Solid and would only let us nest other Solid
components, s are handled by the browser itself and will let us
nest any HTML element -- including web components written with other
frameworks!
The web component wrapper is pretty similar to the example above. It
creates a shadow root in the constructor, and then renders the Solid
component into it in the connectedCallback method.
Note that this is not a complete implementation of the web component
wrapper! At the very least, we'd probably want to define an
attributeChangedCallback method [mdn-social] Using custom elements -
Web APIs | MDN One of the key features of web components is the
ability to create custom elements: that is, HTML elements whose
behavior is defined by the web developer, that extend the set of
elements available in the browser. [favicon-48] developer.mozilla.org
/en-US/docs/Web/API/Web_Components/Using_custom_elements#
responding_to_attribute_changes so we can re-render the Solid
component when the attributes change. If you're using this in
production, you should probably use a package Solid provides called
Solid Element [338e4905a2] solid-element Webcomponents wrapper for
Solid. Latest version: 1.8.0, last published: a month ago. Start
using solid-element in your project by running `npm i solid-element`.
There are 59 other projects in the npm registry using solid-element.
[b0f1a83183] www.npmjs.com/package/solid-element that handles all
this for you.
Back in our React app, we can now use our TodoLayout component:
// TodoApp.jsx
export default function TodoApp() {
return (
Todos
);
}
Note that we don't need to import anything from TodoLayout.jsx -- we
just use the custom element tag that we defined.
Check it out:
JavaScript is required to run this demo.
That's a React component rendering a Solid component, which takes a
nested React element as a child.
Adding Todos
For the todo input, we'll peel the onion back a bit further and write
it with no framework at all!
// TodoInput.js
customElements.define("todo-input", TodoInput);
class TodoInput extends HTMLElement {
constructor() {
super();
this.shadow = this.attachShadow({ mode: "open" });
}
connectedCallback() {
this.shadow.innerHTML = `
`;
this.shadow.querySelector("form").addEventListener("submit", evt => {
evt.preventDefault();
const data = new FormData(evt.target);
this.dispatchEvent(new CustomEvent("add", { detail: data.get("text") }));
evt.target.reset();
});
}
}
Between this, the example web component and our Solid layout, you're
probably noticing a pattern: attach a shadow root and then render
some HTML inside it. Whether we hand-write the HTML or use a
framework to generate it, the process is roughly the same.
Here, we're using a custom event [mdn-social] CustomEvent - Web APIs
| MDN The CustomEvent interface represents events initialized by an
application for any purpose. [favicon-48] developer.mozilla.org/en-US
/docs/Web/API/CustomEvent to communicate with the parent component.
When the form is submitted, we dispatch an add event with the input
text.
Event queues are often used to decouple communication Event Queue *
Decoupling Patterns * Game Programming Patterns [favicon-32]
gameprogrammingpatterns.com/event-queue.html between components of a
software system. Browsers lean heavily on events, and custom events
in particular are an important tool in the web components toolbox --
especially so because the custom element acts as a natural event bus
that can be accessed from outside the web component.
Before we can continue adding components, we need to figure out how
to handle our state. For now, we'll just keep it in our React TodoApp
component. Although we'll eventually outgrow useState, it's a perfect
place to start.
Each todo will have three properties: an id, a text string describing
it, and a done boolean indicating whether it's been completed.
// TodoApp.jsx
import { useCallback, useState } from "react";
let id = 0;
export default function TodoApp() {
const [todos, setTodos] = useState([]);
export function addTodo(text) {
setTodos(todos => [...todos, { id: id++, text, done: false }]);
}
const inputRef = useCallback(ref => {
if (!ref) return;
ref.addEventListener("add", evt => addTodo(evt.detail));
}, []);
return (
Todos
);
}
We'll keep an array of our todos in React state. When we add a todo,
we'll add it to the array.
The one awkward part of this is that inputRef function. Our
emits a custom add event when the form is submitted.
Usually with React, we'd attach event listeners using props like
onClick -- but that only works for events that React already knows
about. We need to listen for add events directly.^3
In React Land, we use refs [og-learn] Manipulating the DOM with Refs
- React The library for web and native user interfaces [favicon]
react.dev/learn/manipulating-the-dom-with-refs to directly interact
with the DOM. We most commonly use them with the useRef hook, but
that's not the only way! The ref prop is actually just a function
that gets called with a DOM node. Rather than passing a ref returned
from the useRef hook to that prop, we can instead pass a function
that attaches the event listener to the DOM node directly.
You might be wondering why we have to wrap the function in
useCallback. The answer lies in the legacy React docs on refs
[logo-og] Refs and the DOM - React A JavaScript library for building
user interfaces [favicon] legacy.reactjs.org/docs/
refs-and-the-dom.html#caveats-with-callback-refs (and, as far as I
can tell, has not been brought over to the new docs):
If the ref callback is defined as an inline function, it will get
called twice during updates, first with null and then again with
the DOM element. This is because a new instance of the function
is created with each render, so React needs to clear the old ref
and set up the new one. You can avoid this by defining the ref
callback as a bound method on the class, but note that it
shouldn't matter in most cases.
In this case, it does matter, since we don't want to attach the event
listener again on every render. So we wrap it in useCallback to
ensure that we pass the same instance of the function every time.
JavaScript is required to run this demo.
Todo Items
So far, we can add todos, but not see them. The next step is writing
a component to show each todo item. We'll write that component with
Svelte [twitter-th] Svelte * Cybernetically enhanced web apps
[favicon] svelte.dev .
Svelte supports custom elements out of the box [twitter-th] Custom
elements API * Docs * Svelte [favicon] svelte.dev/docs/
custom-elements-api . Rather than continuing to show the same web
component wrapper boilerplate every time, we'll just use that
feature!
Here's the code: