(P)react vs Web Components: a Xoogler's perspective
I left Google at the end of 2021, as part of a bit of a great resignation from my team—Surma, Rob and a bunch of others left too. Not for any common reason.
One common thing I think all of us Xooglers are hitting is having to learn about JSX and functional components, which of course are technologies that—as a Googler—I was basically told had already won the web development race.
So I'm going to talk through some of the interesting things I've learned in swapping over. In this post, I'll refer to Web Components as Elements, but (P)react's provided components as Components.
Background
This section is short. If you want to hear more about Web Components, you can see my talk from JSNation. Needless to say, Web Components describes Custom Elements (CEs) and Shadow DOM (SD):
- CEs are about loading code—if your browser sees
<my-foo>
element, it can be told to load your code - SD is about wrapping scoped CSS and HTML inside another element—a good example here is the built-in
<input type="date" />
picker, which has a popup and logic inside it despite beign a single element with a complex API
I'm going to talk a bit about Lit, because that's the WC library I've known.
(Here's a date picker if you wanted a reminder: )
And (P)react… well, the comparison is kind of the point here. So read on 👇
Reference Gotchas
When Polymer, arguably the first Web Components library, was realeased, it relied heavily on the long-deprecated (and near impossible to polyfill) call Object.observe
.
This call observed an object and its entire tree for changes.
Even after this call was deprecated, Polymer still had a weird syntax for trying to handle object changes.
What I find interesting is that (P)react just "leant in" to this limitation. For example, a classic "gotcha" in an interview is what causes a re-render:
const MyComponent = () => {
const [arr, setArr] = useState([ 1, 2, 3 ]);
const clickHandler = useCallback(() => {
// don't do this- "arr" does not change!
arr.push(1);
// do this instead
setArr((arr) => [...arr, 1]);
}, [arr]);
return (<>{arr.map((x) => ...}</>);
};
The first behavior doesn't actually change arr
, so the component won't re-render it.
This is a totally normal limitation.
And I'm not saying this is problem—JavaScript's not a functional programming language, yet we need to treat it a bit like one—but Web Components were, early on, called out for this supposed failing.
Fragments
In JSX, the empty component <></>
is actually mapped to a Fragment
, which I suppose is internally a DocumentFragment
.
(I actually just learned this today.
I had assumed it was some other kind of magic 🔮)
If you've written much (P)react, I suspect you know this is a really useful concept. A component might not map directly to O(1) parent—it might render many, or no, content. This gives you a bunch of versatility, even to say, add components which purely do imperative things:
class NoDOMComponent extends Component {
componentDidMount() {
alert('This component just gives an annoying alert on mount!');
}
render() {
return <></>;
}
}
We can sort of match this in a Web Component, but we'll always end up with a real DOM element on the page:
class NoDOMElement extends HTMLElement {
connectedCallback() {
alert('This element just gives an annoying alert on mount!');
}
}
customElements.define('no-dom-element');
// We have to create it like a regular element for it to be used.
const el = document.createElement('no-dom-element');
document.body.append(el);
Again, we can't avoid the <no-dom-element>
being added to the page.
There is a somewhat modern CSS property which helps us—display: contents
—and that basically makes our element disppear from rendering and replace itself with its children, which can be, as per the component case, zero to many child elements.
class NoDOMElement extends HTMLElement {
constructor() {
super();
const root = this.attachShadow({ mode: 'open' });
root.innerHTML = `
<style> :host { display: contents } </style>
<h1>Hi! I'm treated as a peer of <code>no-dom-element</code>!</h1>
`;
}
}
But!
It's not quite the same, mostly in terms of developer experience.
In (P)react, we control what properties a component can accept—that includes things like style
, or event handlers.
In the Web Component case, we still have a real element—it's just neutered, so applying style to it doesn't make sense—and the user of that element doesn't really know that it's purely a kind of placeholder.
Wrapping Up Behavior & Developer Experience
The ease of which I can add a functional component to a project in (P)react is just outstanding. Here's a toy component which says hello:
const HelloComponent = ({ name }) => {
return <>Hello, {name}</>;
};
If I wanted to build that in a Web Component… I won't include the code, it's ~20 lines long. I feel like this has been said over and over by folks working on component systems, most famously by Rich Harris in 2019.
I think the best counter-argument here, even though it's kind of a straw person, is that you're holding it wrong—"it's an unfair fight, because Web Components are designed to be low level"—and you should use libraries like Lit.
XML
This is a late addition.
In my youth, I spent years writing XHTML—insisting that I wanted to be "good to the parsers", and not leave ambiguity (who allows naked <p>
elements? When do they end?!).
And I was told that I was too prescriptive and I should be free-flowing with my markup.
And along comes JSX, where… everything needs to be perfectly balanced. It's fine, I just find the circle of life here amusing. ⚖️
Styling
(P)react seems to have a million CSS-in-JS libraries which are all highly competitive. Like with Web Components being held wrong, I think those projects would say—it's not our problem, we just help you write components, and that is reasonable.
But Shadow DOM gives us the ability to scope CSS to an element's shadow root, encapsulating styling so it won't leak out—I don't have to worry about keeping my class names consistent and I can basically forget about inheritance problems. It looks a bit like this:
class BlueHeadingElement extends HTMLElement {
constructor() {
super();
const root = this.attachShadow({ mode: 'open' });
root.innerHTML = `
<style> h1 { color: blue; } </style>
<h1>No other H1s under me will be blue</h1>
<slot></slot>
`;
}
}
And now I can use my <blue-heading>
whereever I need an element with a… blue heading. 🔵
However, this is great, but isn't actually a vote for Web Components because… you can just use this in (P)react, too, if you want. A cursory Google search reveals libraries that let you do this:
const ShadowComponent = () => {
return (
<div>
<ReactShadowRoot>
<style> h1 { color: blue; }</style>
<h1>I'm blue!</h1>
<div>
This element will not be blue:
<slot></slot>
</div>
</ReactShadowRoot>
<h1>I'm not blue!</h1>
</div>
);
};
And yeah, it's a little verbose. But it works well and uses the built-in features of the platform within our higher-level component library—so, kind of the best of both worlds. 🌎🌏
Hooks Are Awkward
Going off the beaten path with components is hard.
Things like timeouts, callbacks, anything async
—I find these really difficult in (P)react.
But to get ahead of the counter-point, they're difficult in Web Components too.
This is a pretty standard bit of (P)react code you might see:
const LoadFromNetwork = ({ url }) => {
const [result, setResult] = useState(null);
useEffect(async () => {
const r = await fetch(url);
const json = await r.json();
setResult(json);
}, [url]);
return <>The JSON has value: {json?.value}</>;
};
But, the useEffect
hook doesn't actually care that I'm passing it an async
function.
This has two serious implications:
- If our
fetch
throws an error, where exactly does it go? - If
url
changes, we aren't able to cancel our previous network request—it's just run again- And even worse: if the second call finishes before the first,
setResult
will get called out of order 😱
- And even worse: if the second call finishes before the first,
And honestly, I see this kind of behavior all over the (P)react code I've looked at. It often works mostly out of luck, and not having built-in behavior for this is a disservice—everyone is BYO'ing crappy solutions. Love or hate your (P)react built-ins, they're there for free so they get used.
Timeouts have similar awkwardness, because it's not clear what context their callbacks get invoked in. This naïve approach doesn't work:
const TimeoutComponent = () => {
const [someValue, setSomeValue] = useState(0);
useEffect(() => {
const id = setTimeout(() => {
// This will ALWAYS output zero- we're in the scope of the 1st render.
console.info(`Value is now: ${someValue}`);
}, 1000);
return () => clearTimeout(id);
}, []);
return <>...</>;
};
So we have to wrap our callback like this:
const TimeoutComponent = () => {
const [someValue, setSomeValue] = useState(0);
// Update callbackRef whenever someValue changes, as we operate on it.
const callbackRef = useRef();
useEffect(() => {
callbackRef.current = () => {
console.info(`Value is now: ${someValue}`);
};
}, [someValue]);
useEffect(() => {
const id = setTimeout(() => {
// And we have to indirect to callbackRef, because the setTimeout
// param itself is always in the 1st render scope.
callbackRef.current();
}, 1000);
return () => clearTimeout(id);
}, []);
return <>...</>;
};
…yeah, it's not great.
Functional components are much worse than class components here; mostly because they eskew a concept of this
that you always have access to.
Proponents of functional components could argue you should do something like this:
const ComponentWithCommonSelf = () => {
const self = useRef({
someValue: 1,
}),
// ... `self` will be the same ref on all renders
};
But we won't automatically re-render when self.current.someValue
changes, or even when self.current
is replaced with an entirely new object, because component models only care about the top-level self
—which never changes, which is again, the point.
(Apologies for the awkward explanation.)
Web Components, to be fair, make this a bit awkward too, but again, they're equivalent to (P)react's class components in terms of their lifeycle.
You have to control things like timeouts, async
, with the mild benefit of always having a this
accessible to you.
You still have to manually trigger a re-render of your element or component (however that's done for you).
Components/Elements As Props
One idiom I've really liked in (P)react is passing rendered components down as props. Something like this, where I'll render a button like :
const UseAsArg = () => {
return (
<>
<Button icon={<BoatIcon />}>Boat</Button>
</>
);
}
There's nothing stopping Web Components from supporting this, but it's just not very common.
Without a library, inserting a property of type HTMLElement
into an existing Shadow Root is awkward.
Lit is better at this, and lets you write render calls like:
class FooElement extends LitElement {
render() {
return html`Your element: ${this.someElementProp}`;
}
}
Yet the scoped styling may actually result in an experience not like you expect—you're no longer just placing an icon, let's say, but you're actually scoping it somewhere else.
This brings me to Light DOM vs Shadow DOM.
Light vs Shadow
In both props and styling above I've talked about the pros and cons of Shadow DOM. It's great for encapsulating some known styles, like if you're building a self-contained button or UI widget—something that you could imagine might be built by the browser for you.
But I think it causes a ton of pain around interopability.
One of WC's advantages is that I can just define an element—<my-foo>
—and have it work anywhere.
And if I'm using Shadow DOM, that's mostly true.
Light DOM?
Not so much.
The element <my-foo>
might need to create or expect sub-elements around it to exist.
When I was at Google, a few years back, we constantly struggled with this: SD wasn't widely supported, so our <my-foo>
would, on creation, literally re-arrange the DOM nodes below it in order to construct its Light DOM.
This was super awkward—your expected layout would suddenly just change:
<my-foo>We're progressively enhancing this area</my-foo>
<!-- after mount: -->
<my-foo>
<div class="weNeededToAddThisCrap">
<span class="someWeirdLayer">
</span>
<!-- and finally place the existing content somewhere -->
We're progressively enhancing this area
</div>
</my-foo>
Not really ideal for something that can just "work anywhere".
Nearly all of (P)react's components, ignoring the quite novel <ReactShadowRoot>
component above, live in the light DOM.
And by accepting that (P)react owns or manages a whole subtree, because eventually it bubbles up to a top-level render()
call that takes ownership of its target node.
Which brings me to some summary thoughts. 💭
Summary
This has just been an exploration of some of my learnings around the nuances of (P)react vs Web Components.
I've obviously learned a lot writing components in the past six months, and I've not just given say, a detailed description of how useCallback
works.
You can find some great resources on that already.
There's another topic here about what's fit-for-purpose: is it right to use (P)react for a content site?
Web Components have a niche here, because I can just put <my-foo>
somewhere as a pure progressive enhancement, rather than controlling the whole render.
And the answer is probably not—both React and Preact mostly work without JavaScript enabled—although now we're getting into whether they're SSR'ed or not, which is out of scope of this post. 🙅
Thanks for reading this far! Hit me up on Twitter or subscribe to the feed. ☑️