Web Components v1 are a landmark achievement that render identically in stable versions of Firefox, Safari, and Chrome. While they are fantastic in concept, they are clunky to use raw, and do not integrate well with React components.
When I look at proposals created by the w3c community to help move this specification forward, I'm confused as to who the target audience is. Browser vendors or application developers?
I specifically want to call out two proposals:
Who are these for? When I read Pro Web Component / Anti React posts like this, it would seem that Web Components are a powerful specification that solve the same problems and are a suitable substitute for React, but that doesn't line up with my experience. I lean closer to the camp that wants the web to learn from React.
My feedback is provided in the form of wishes: problems I hope get fixed, but not necessarily in the way I envision. These are problems from the perspective of a developer who has written a Web Component VDOM library, works on a React -> WC bridge, and wants to create a design system as Web Components.
As I am not a specification author, I fully acknowledge my suggested solutions may not make sense to this community, as their proposals do not make sense to me. My hope is that they will take the problems and use cases into consideration, seeing the suggestions as only exercises. Also understanding that React developers could easily integrate and benefit from Custom Elements if they weren't limited in the respective ways outlined below.
To these spec authors and browser vendors out there, I have three wishes for you:
🤞 Wish #1: Decouple DOM from Style Encapsulation
The Shadow DOM is very useful for seamlessly protecting a region of child DOM
nodes. Unlike <iframe>, which can also protect nodes in a separate document,
the Shadow DOM approach allows the nodes to appear as though they are part of
the main document. A long time ago, specification authors attempted to land a
seamless attribute for <iframe>, which did a very similar thing, but this
has since been abandoned.
An issue with the current Shadow DOM implementation, is that it couples this
notion of DOM encapsulation in with style encapsulation. This means protecting
a region of Nodes from modification, excludes them completely from host
document styles. If we look into what we can configure when attaching a Shadow
DOM, we can use the mode=open|closed option to control whether or the DOM is
completely encapsulated or not, but no such toggle exists for style.
While it is possible to selectively allow styles to cascade into the Shadow DOM, this feature is exclusively designed for when you have complete knowledge and access of the rules. Most likely not useful unless you're making third-party components, advertisements, or native browser elements.
☞ A Suggestion
Implement a style option to the attachShadow configuration object. Similar
in design and concept to mode, this new option would accept one of the
following values:
isolate- Previous, and the default behavior, disables arbitrary CSS cascadecascade- A new behavior, allows host styles to cascade
class CustomElement extends HTMLElement { connectedCallback() { this.attachShadow({ mode: 'closed', style: 'cascade', }); } }
🤘 Use case: React owns the DOM
On the surface, rendering a React component as a Web Component seems fairly straight-forward. When the DOM is mounted, render the backing Component into the Custom Element (which is a DOM Node). The problem here is that this would put all React-rendered Nodes into the host document tree. This makes them available for any other scripts to unintentionally modify.
This is a problem, because React snapshots the state of the DOM after rendering and expects that exact state to remain unchanged. If it does change, React will refuse to render is a non-ideal way.
<some-react-component> <p>This will be part of the SomeReactComponent tree</p> <p id="changeme">Change me!</p> </some-react-component> <script> // After one second, change the DOM. setTimeout(() => { // Now the React tree is invalid. changeme.innerHTML = '<span>Whoops</span>'; }, 1000); </script>
What we can do instead, is attach a closed Shadow DOM and instruct React to render into this. This protects the DOM Tree from being modified from code that isn't React.
<some-react-component><!-- Render tree is in the Shadow DOM --></some-react-component> <script>/* Unable to change anything within some-react-component */</script>
This solves the above concern. But, what if your component uses a Stylesheet to
theme itself? Or what if it uses Styled Components, which generates and
extracts class names into a host-level <style> tag? They break :(
🤘 Use case: Slots without style encapsulation
Changing the presentation of children through the parent element is desirable when using design system components. You want to use the Grid component to layout your design, and the Text component to ensure typography consistency.
This feature was coined transclusion by the Angular team, outlets by the Ember team, and children interpolation with React. Slots are the respective feature in the Shadow DOM to do the same thing. You nest elements inside your Web Component, project them through the Shadow DOM, and perform whatever decoration you want without cluttering the host tree.
Imagine you were writing a blog post and laid out your design using the
hypothetical Grid and Text components. You wanted to set a common color,
contrived example, for a specific section. You wouldn't be able to do this, the
nested component Text is in the Shadow DOM and the cascade you know and love
is gone.
<some-grid style="color: red"> <some-text>Some award winning content</some-text> <some-text>Really top notch work</some-text> </some-grid>
🤞 Wish #2: Extend the HTML grammar to support property setting
A very common implementation detail that every framework that renders HTML needs
to address is setting properties vs attributes. This feature is critically
required and can take direct influence from highly popular existing
frameworks/standards such as: JSX, lit-html, and Vue. I have heard there is
some movement around unifying the observedAttributes to properties, but I am
dubious if this can truly replace distinct property vs attribute handling.
The current approach to handling properties with a vanilla Web Component is to gain a reference to the DOM node and imperatively set properties. This is a non-starter for a React developer used to a declarative approach.
There will be issues, such as attributes and Attr having existing
incompatible behavior, but instead of firmly rooting HTML in the past, can we
extend it to support the future of Custom Elements?
☞ My Suggestion
Extend the HTML grammar to support a prefixed @ attribute to denote property
vs attribute. By default, HTML attributes are lower-cased, but whenever a prop
attribute is encountered, they will remain case-sensitive.
<some-component @someProp="someValue"></some-component>
Frameworks and libraries should not need to invent new ways to get around the limitation HTML has imposed. The specification can change to support the future.
🤞 Wish #2.5: Extend the HTML grammar to support interpolation
In addition to property setting, a natural progression is the ability to interpolate JavaScript expressions from within HTML. Before you scream no directly into my ear, hear me out. Developer experience when working with components should mirror what they are used to with existing popular frameworks.
Developers should be able to grab-and-go with a Web Component without needing to fetch a reference to the DOM Node to configure its properties. This dance is tiring and unproductive:
<some-rad-component id="ref"></some-rad-component> <script> const { ref } = window; ref.onClick = function() { console.log('here'); }; </script>
Web Components will realistically expect more than just String and Boolean input, which is all HTML is currently able to describe as attribute values. If the grammar were extended to support property setting, then it would make sense to support a way to assign JavaScript values.
☞ My Suggestion
Extend the HTML grammar to allow for JavaScript value interpolation for:
properties, attributes, and children. This would evaluate anything between the
{ outer brackets } , and assign as-is to properties, or coerce to string
for attributes and children.
<some-rad-component @onClick={() => { console.log('here'); }} ></some-rad-component>
🤞 Wish #2.75: Extend the HTML grammar to support self closing Custom Elements
Once properties can be set, and JavaScript values interpolated, we are nearly at foundational parity with React and JSX. Why not sprinkle a little of that DX secret-sauce in, by reducing the amount we need to type?
☞ My Suggestion
Extend the HTML grammar to allow all Custom Elements to self-close if they do not have nested children.
<some-component style="display: inline-block" @theme={{ color: 'blue', }} />
🤞 Wish #3: Make HTML Templates more powerful
HTML Templates are currently one of the most useless features of the Web Components stack. They are an artifact from a bygone development era, and require either a caffeine injection or a swift kick in the butt out of the spec. This wish is very related to the Template Instantiation proposal, linked at the top, but I think is closer aligned to what web developers will expect.
With the other requested features in this post, I believe the <template>
tag can become truly powerful.
☞ My Suggestions
Introduce a template property to DOM Node's that mimics the behavior of the
style property. You set a template literal to this property and it will
create a backing HTMLTemplate element instance and assign it to this
property. When class properties land, this will be the ideal way to
declaratively set a template. This will automatically become the contents of
the component instance. If the user wants to transclude, they can interpolate
the innerHTML of the instance. If a Shadow Root exists, that will be used
instead.
Expose a render method on the <template> elements, which binds the markup
and interpolated properties to a passed DOM node. Keep them in sync whenever
the render function is called with new values, which are provided as the
context of the template.
class RadButton extends HTMLElement { template = ` <button @onClick={props.onClick}>{innerHTML}</button> ` style = ` --color: var(--rad-button-color, #000); color: var(--color); ` props = { onClick: null, } set onClick(label) { this.props.label = label; this.render(); } get onClick() { return this.props.onClick; } render() { this.template.render(this); } } customElements.define('rad-button', RadButton);
This render method should emulate what a Virtual DOM accomplishes, where only
updated nodes and attributes are changed. This would make it highly suitable
for pairing with a Custom Element that is reactive.
Putting it all together
With these changes in place, porting React components to Web Components becomes significantly easier and writing raw Web Components starts to match the declarative and reactive beauty of React. Significantly less plumbing occurs with this solution as well. Below you will find a full example using these changes to render a trivial Grid component.
<rad-button @color="red" @onClick={evt => console.log(evt.target)} @innerHTML={` <span>Inside a Rad Button</span> `} /> <script> class RadButton extends HTMLElement { template = ` <button @onClick={props.onClick}>{innerHTML}</button> ` style = ` --color: ${this.props.color}; color: var(--color); ` props = { onClick: null, color: 'black', } set onClick(label) { this.props.onClick = onClick; this.render(); } set color(color) { const { props, style } = this; props.color = color; if (color) { style.setProperty('--color', color); } else { style.removeProperty('--color'); } } get onClick() { return this.props.onClick; } get color() { return this.props.color; } render() { this.template.render(this); } } customElements.define('rad-button', RadButton); </script>