Web Components: The Unsexy Solution to Cross-Framework UI
An R&D experiment on how might approach the cross-framework UI challenge at SuperTokens with Web Components and why they're not as bad as you might think
This article is based on a recent talk I gave at a few conferences. Consider this article a more detailed version of that talk.
I can feel you rolling your eyes already. “Web Components? In 2024? Really?” Trust me, I’ve been there. But hear me out - sometimes the unsexy solution is exactly what you need, especially when you’re trying to solve a pretty thorny problem like shipping prebuilt UI that works everywhere.
The Problem: Framework Soup
At SuperTokens, we faced an interesting challenge: how do you build authentication UI that works for literally everyone? And I mean everyone - React devs, Vue enthusiasts, Angular aficionados, and whatever-comes-next adopters.
We could have gone the “obvious” route:
Build separate implementations for each framework. Which, as you may know, requires an army of developers. Developers cost money, and we’re not FAANG (or whatever it’s called now) big. Yet.
???
Profit! (and by profit, I mean constant pain)
So, Web Components? Web Components. Keep on reading for the why.
The Drama Intermission
A few months ago, there was this huge drama on the internet about how Web Components are not the future. While I agree with a lot in that article, the problems described in there are framework people problems. And I’m saying that with all the respect in the world for Ryan’s work and everything related to Solid - I’m a happy Solid user. It had a very strong effect on socials, and it polarized the community. Here’s a picture:
On one side, you have the framework authors, arguing that Web Components kinda suck and there’s a cost to supporting them that ultimately hurts the user. Sure, fair enough. On the other side, you have Web Components and Standards people, arguing that not supporting web components is a moral failure, and web components are the future. And then, there’s us, the average (peasant meme) developers, who are just trying to get their job done. And I’m not trying to say that framework people problems are not real, or that standards people problems are not real. I’m just saying that, statistically speaking, the average developer is not facing these problems. Y’know, right tool for the right job. And while some of those tools are a compromise by every measure out there, they are still worth making, because they are solving real problems.
Web Components: The Ugly Duckling That Actually Works
Let’s get the boring part out of the way first. Web Components are basically DIY HTML tags built on three core technologies:
// Custom Elements API - Define your own HTML tagsclass HelloWorld extends HTMLElement { connectedCallback() { this.textContent = "Hello World!"; }}// register componentcustomElements.define("hello-world", HelloWorld);
I’ll spare you the raw API examples because, honestly, MDN does a great job at explaining it. Plus, one of the initial arguments against web components was that their DX is kinda ancient (and we tend to like JSX). This is a very biased take, of course - I’m very much into the JSX style of thinking and writing components. But here’s the thing - Web Components aren’t your next React or Vue component. In my, again, very biased opinion, they’re your next <input> or <video> - standard building blocks that just work everywhere.
Making Web Components Not Suck with SolidJS (and others)
Here’s where it gets interesting. We didn’t want to write raw Web Components because, well, JSX. Plus, our engineering team is used to writing in React. Instead, we went for solid-element to make the development experience more pleasant:
import { customElement } from "solid-element";// Look ma, no class-based components. And with JSX!customElement("hello-world", () => <p>Hello World!</p>);
Shorter, prettier, and much more stomachable to React devs. You can split the component and write it as a regular Solid component too, if you’d rather do that.
Examples: the story of how Darko got to answer a ton of questions from the engineering team
This is how it all went down, in a nutshell:
- Engineering team: "How do we solve X using this approach?"- Darko: /Goes on a rant about the theory of it.- Engineering team: "But how do we build it?"- Darko: /Goes on to build a demo for the call we scheduled for next week.
Rinse/repeat for close to two months.
Here’s a non-exhaustive and vaguely timeline-correct list of those questions:
Now, with those in place, you can define components like this:
// src/components/Basic.tsximport { customElement } from "solid-element";// We have to name this component "c-basic" because the custom element name must have a hyphen in it.// The biggest reason for this is to differentiate between custom elements and built-in elements, due to how the DOM works.customElement("c-basic", {}, () => { return <h1>Hello world!</h1>;});
You also need a “loader” file for the demo to work, where you import the component:
// src/main.tsximport "./components/Basic";
Now, you can use the component in your HTML:
...<c-basic></c-basic>...
2. Okay, how do we pass props to it?
Alright, cool. So we know how to define a component, in the most basic form. But how do we pass props to it?
Easy (with a catch):
// src/components/Props.tsximport { customElement } from "solid-element";// The second argument is the default prop values. Otherwise, you can use props much in the same way you'd use them in Solid.customElement("c-props", { myMessage: "hello world!" }, (props, {}) => { return <h1>{props.myMessage}</h1>;});
Using the component in HTML:
...<c-props my-message="Hello from the DOM!"></c-props>...
Leaving the prop out, defaults to the myMessage value from the default props. One thing to note here is that the prop name must be in kebab-case, but solid-element will convert it to camelCase for you.
But wait, there’s a catch. The only two types of data you can reliably use as props are strings and booleans. Because those are the types HTML attributes support - and yes, we’re dealing with HTML attributes here, being converted to props on the other side. We have ways around this, though:
We can stringify and parse any data (but that’s a bit hacky).
We can access the DOM node directly and pass data that way. Or, alternatively, events (if that makes sense for your use case).
3. Cool, can we embed children?
Yes. But, it also comes with some caveats.
The idea of Reac children doesn’t translate exactly 1:1 to Web Components. Instead, you can use slots (which happen to be a standard, and work very similarly to how Vue’s slots work).
Slots come in two flavors: named and unnamed. The unnamed slot is the default slot - meaning, it’s the slot that will be used if you don’t specify a slot name:
...<c-slots> <!-- This will go in the header slot --> <h1 slot="header">Header</h1> <!-- This will go in the default slot --> <div>Content</div> <!-- This will go in the footer slot --> <h3 slot="footer">Footer</h3></c-slots>
So okay, children kinda solved. But, again, there’s a catch. Slots only work with the Shadow DOM. Or you’ll have to shim them, which comes with a performance penalty (not too bad though, and exactly what we did). It’s not that the Shadow DOM is all bad, but for our use case it doesn’t make sense at all - it breaks password managers. Being an auth company, that’s not exactly a good thing for us.
4. Okay, how do we handle events?
This one comes with a couple of potential answers. You can use the standard events directly on the DOM node itself (so, onclick…), or you can use the addEventListener method. Using the web component inside a framework, you can also use the framework’s event system to handle events (i.e. React’s onClick). You can also do fancier things, such as:
What this does is it allows you to pass a function to the component, which will be called with the DOM node as the argument. This is useful for handling events, but also for other things like exposing state (as an alternative to custom events).
So, in a nutshell, handling events is not a big issue.
5. How do we ship a whole component with styles and state?
While CSS may get a bit interesting, especially if you’re using the Shadow DOM, you can do one of the following:
Inline styles. Even use a framework for it.
Import a CSS file and bundle it with the component.
Use CSS parts. For me, they were a bit weird to get into, but they work.
Avoid the Shadow DOM and write CSS as usual, targeting the component as you would regularly. For us, this was the solution, because we aren’t using the Shadow DOM anyway.
On state, especially if it’s internal, you can use whatever you feel like. Here’s an example of a counter component:
When you use this in an HTML file, it will come pre-styled, using the CSS file we import. And, since I’m not using the shadow DOM here (see the noShadowDOM call), you can easily override it any way you want.
6. What about exposing state?
If you need to expose state from within the web component, you can once again, tap the DOM node directly:
// src/components/Signals.tsximport { customElement, noShadowDOM } from "solid-element";import { createSignal, createEffect, onMount } from "solid-js";customElement("c-signals", { initialCount: "0" }, (props, { element }) => { noShadowDOM(); const initialCount = parseInt(props.initialCount); const [count, setCount] = createSignal(initialCount); onMount(() => { // Either expose the signal directly on the DOM node element.count = count; element.setCount = setCount; // Or use events element.addEventListener("set-count", (event: CustomEvent) => { setCount(parseInt(event.detail.count)); }); }); createEffect(() => { element.dispatchEvent( new CustomEvent("count-set", { detail: { count: count() } }) ); }, [count]); return ( <div> <button onClick={() => setCount(count() + 1)}>Increment</button> <p>Count: {count()}</p> <button onClick={() => setCount(count() - 1)}>Decrement</button> </div> );});
The example above is a once again a counter, which exposes internal state (count, setCount) directly to the DOM via the DOM node.
I don’t imagine this being a common use case, but it’s there if you need it.
7. Can we do SSR?
Yes. And, surprisingly enough, it’s not even that exciting (as is the common narrative on the internet). When you think about it, SSR is just the pre-hydration HTML present at the time of the request. So, if you’re to provide that HTML, render the hydrated version over it on the client, you’re good to go:
// src/components/SSR.tsximport { customElement, noShadowDOM } from "solid-element";// import { renderToString } from "solid-js/web";const Component = () => <div>Hello world (client-side)!</div>;customElement("c-ssr", {}, (props, { element }) => { noShadowDOM(); element.innerHTML = ""; return <Component />;});// export const getString = () => renderToString(Component); --> This goes on the server
Note: the renderToString part if present to illustrate how you might go about it in a metaframework scenario - your metaframework would have to get the string somehow. It’s common to use a renderToString approach in these scenarios.
...<c-ssr> <p>Hello world (server-side)!</p></c-ssr>...
If you simulate this on a slow network, you’ll see that the server-side HTML is rendered immediately, and then the client-side HTML is hydrated over it. Not particularly exciting.
Where This Approach Actually Makes Sense
Let’s be real - Web Components aren’t the answer to everything. But they’re perfect for:
UI Libraries: When you need your components to work everywhere. And, why not? If you’re building a component library, or a design system, most of what you’re building are leaf components. So, why not make them work everywhere?
Projects like SuperTokens: When you need to ship UI that works everywhere. It’s either something like web components, or an army of developers.
Widgets, embeddables: Think authentication modals, chat widgets, etc. We’d have to fall back to iframes or that weird <script> + div with an id combo, which feels like a step back comparing to just using a web component.
Plus, whether we like it or not, any web component out there will quite possibly outlive your favorite framework. For better or worse.
The Takeaway
Web Components aren’t going to replace your favorite framework, and that’s okay. They’re not meant to. They can complement your favorite framework and they’re meant to be the boring, reliable solution for building UI that needs to work everywhere, all the time. Is it a compromise? Yes. But it’s a compromise worth making when you need to ship UI and not care about the 1001 frameworks out there.
Think of them like the Toyota Corolla of web development - not exciting, but they’ll probably outlast whatever shiny new framework drops next week.
Want to see the actual experiment? Check out the GitHub repo for the full code. Fair warning though, dragons be there. It’s an R&D prototype, ergo not too pretty. But it works - offering a fully working solution for the SuperTokens’ Email/Password recipe.
Remember: Sometimes the unsexy solution is exactly what you need. Just maybe don’t put that on your dating profile.
Liked this article? Subscribe to the newsletter and get notified of
new articles.