^ Photo of a bear-proof garbage container from Humpback Rock in the Blue Ridge Parkway (Virginia). Note: LitElement is not garbage, but I couldn't resist the pun.
This is an opinionated and unordered list of the downsides of lit-element and web components compared to React. It does not list any downsides of React, so take it with a grain of salt. Many of these reasons apply to other WC frameworks, and even the spec itself.
Default values are more complicated
Web components are classes*, and input is given as properties. We often want to default the properties, giving them a default value when a value is omitted. In functional patterns like angularjs link functions or React function components, this is done with a single variable assignment at the beginning:
link: (scope) => {
const closeDelay = isDefined(scope.closeDelay) ? scope.closeDelay : 300;
...
** WCs don't have to be classes. See matthewp/haunted which uses hooks like React. But that library is not a mixin or wrapper around lit-element; it would replace lit-element. It does use lit-html though.
Why does this matter? It's just a more burdensome way of coding. This may be tenable, but our code needs to resolve defaults very often, and focusing on small details can distract from focusing on bigger issues like data flow and asynchronicity.
Property initialization is an antipattern
class MyThing extends LitElement {
@property({type: Number})
closeDelay = 300;
...
While this may seem to be a solution, it does not achieve the kind of idempotent defaultness that we want. We want the value to always have a default, not just at the beginning.
// Oops the default is gone:
el.closeDelay = undefined;
Suboptimal solutions
Defaulting everywhere
Instead of resolving the defaulted value in one place, it gets resolved in every usage site:
...
setTimeout(fn1, this.closeDelay ?? DEFAULT_CLOSE_DELAY);
...
setTimeout(fn2, this.closeDelay ?? DEFAULT_CLOSE_DELAY);
...
The "defaulting everywhere" workaround is suboptimal because it's error prone and complexifies the code.
Using a getter property as a proxy
class MyThing extends LitElement {
@property({type: Number})
closeDelay: number;
get defaultedCloseDelay (): number {
return this.closeDelay ?? DEFAULT_CLOSE_DELAY;
}
...
This is ok but still suboptimal because it adds noise, and the closeDelay
property remains at risk of getting mistakenly used.
...
setTimeout(fn1, this.defaultedCloseDelay); // ok
...
setTimeout(fn2, this.closeDelay); // oops!
...
Compared to classes, functions provide the simplest pattern for resolving default values.
Property validation / sanitization / transformation / deriving data is more complicated
When a component receives a property value, and:
- validates it
- sanitizes or transforms it (trimming spaces, normalizing)
- deriving data from it
There's no good place to do this. In React functional components, you'd do this simply at the top of the function, or within useMemo
if you need to memoize it.
Similar to the "default values" issue above, the solutions require using a secondary property or getter or some other mechanism.
Memoization is not well supported
Strong memoization patterns are needed in order to avoid duplicate computation and duplicate rendering.
lit-html has guard
which memoizes based on a depends array. It wraps the value in a function, which is a little weird for functions. React has a separate useCallback
for functions and useMemo
for non-functions.
guard([ ... ], () => () => {
...
React hooks have memoization strongly ingrained into them, with well-established lint rules (eslint-plugin-hooks
) to catch mistakes. It's really easy to forget to maintain the contents of the depends array when you change the variables used in the memoized function. Lit-html's guard
directive currently doesn't have any eslint rules to check this, which will certainly bite everyone continually.
"Property is not definitely assigned in the constructor" — classes just aren't meant for this
Using class properties as inputs doesn't mesh well with typescript.
From working with legacy angularjs components, I'm used to seeing this error and either "taping over the warning light" by asserting non-null (!
), or suffering through always guarding a possibly-undefined value that I'm never really sure about.
This is a consequence of using class properties as inputs. Normally, class inputs come from constructor parameters, but with LitElement, the inputs are properties. In React, input comes from constructor params (for class components) or function params (for function components), so it does not suffer from this issue.
No way to enforce required properties
lit-analyzer does not support enforcing required properties (runem/lit-analyzer!74), so a user can leave off any & all properties.
This forces all properties to be defined as optional, which complicates the code. Alternatively, using non-null assertions is risky (and arguably wrong in this case) and erodes confidence in the types.
React via JSX does type-check all props properly, including enforcing required properties.
No support for generics
In typescript, generics establish relationships between two values, whether that's two function parameters, or two properties of an object. In components, there are opportunities where we want to add these constraints to the component props. Such as a selector that accepts a set of objects, and a callback that receives the user-selected object. The callback must be a function whose parameter type matches the union of all object types. Generics allow you to write these types without hardcoding this type into the component.
Generics are also needed for type inference. Without generics, we miss out on some of the best parts of typescript. This limits what types we can express on our component interfaces.
Teardown is more cumbersome
Event listeners added on connectedCallback must be removed on disconnectedCallback. Below is a more complicated (but real) example from a "menu trigger" component. Compare the LitElement version to the React Hooks version:
LitElement
@customElement('menu-trigger')
export class MenuTrigger extends LitElement {
@property({type: String})
trigger?: string;
private eventHandler?: () => void;
connectedCallback () {
super.connectedCallback();
if (!this.isConnected) return;
this.registerHandler();
}
disconnectedCallback () {
super.disconnectedCallback();
this.deregisterHandler();
}
shouldUpdate (changedProperties: PropertyValues<MenuTrigger>) {
if (changedProperties.has('trigger')) {
this.deregisterHandler();
this.registerHandler();
}
}
render () {
return html`<div></div>`;
}
private registerHandler () {
this.eventHandler = () => {
...
};
this.addEventListener(this.trigger, this.eventHandler);
}
private deregisterHandler () {
this.removeEventListener(this.trigger, this.eventHandler);
}
}
Every single line of code here is required. I have simplified this as much as possible.
React
function MenuTrigger ({trigger}: {trigger: string}) {
const eventHandler = useCallback(() => {
...
}, []);
const [el, setEl] = useState<HTMLElement>(null);
useEffect(() => {
if (!el) return;
el.addEventListener(trigger, eventHandler);
return () => el.removeEventListener(trigger, eventHandler);
}, [el, trigger, eventHandler]);
return <div ref={setEl} />
}
It's amazing how much cleaner the React version is.
In this example, beyond registering a listener and deregistering it on teardown, we also needed to handle the trigger
event string itself changing. While some might say "just don't support that", this example serves to illustrate a common development task: dealing with cascading changes — values based on other values, and state based on values, and multiple levels of this.
The hooks pattern is more linear than the class-based pattern. The execution always goes top-to-bottom. In contrast, the class has three possible starting points: connectedCallback
, shouldUpdate
, and disconnectedCallback
.
The hooks pattern takes advantage of closures for retaining the identity of callback functions. In the class-based paradigm, you must store the reference, since it must be bound with Function.prototype.bind
, or as in my example: an anonymous arrow function.
React Hooks is better because it's more concise without sacrificing meaning, and easy to follow. The class-based example is full of noise and hard to follow.
I concede that React's memoization patterns can be hard to wrap one's mind around, and the "what invalidated my memoized value?" question can be hard to debug. But I also wonder if that's just the nature of asynchronous programming and stateful systems?
I personally would greatly prefer to write code with hooks instead of any class-based scheme.
Tied to the DOM
Web components do require an Element in order to exist. There are ways of sharing template fragments, but that has its limits. Adding extra HTML elements can conflict with CSS selectors and break existing styles, so this adds burden to migration.
In the React world, components don't even have to have DOM presence. At its core, React is a state management library. DOM is just a render target. This is why React can be used to write native apps and other things. Allowing components to represent things, not just DOM elements, allows for more expressive APIs.
styleMap issues
Rejects undefined values
This is an issue with the type. Can't pass undefined
as a value, even though it's equivalent to not passing an entry at all. We should be able to pass nullable values.
style=${styleMap({
top: top === undefined ? undefined : `${top}px`,
// ^^^^
// Type 'string | undefined' is not assignable to type 'string'.
// Type 'undefined' is not assignable to type 'string'.ts(2322)
right: right === undefined ? undefined : `${right}px`,
bottom: bottom === undefined ? undefined : `${bottom}px`,
left: left === undefined ? undefined : `${left}px`,
})}
Because of this, you have to @ts-ignore
or conditionally assemble the object (ew)
const style: Record<string, string> = {};
if (top) style.top = `${top}px`;
if (right) style.right = `${right}px`;
if (bottom) style.bottom = `${bottom}px`;
if (left) style.left = `${left}px`;
You can't use Partial<CSSStyleDeclaration>
because that has optionality.
Requires all strings
In React, numbers are interpreted as pixel values, which is nice for convenience. styleMap
doesn't do this, so the resulting expressions can get awkward:
LitElement
style=${styleMap({
top: top === undefined ? undefined : `${top}px`,
right: right === undefined ? undefined : `${right}px`,
bottom: bottom === undefined ? undefined : `${bottom}px`,
left: left === undefined ? undefined : `${left}px`,
})}
React
style={{
top,
right,
bottom,
left,
}}
That's it for now.
Note: This page is mostly data, and mostly objective comparisons. Though I called some things "better" than others, I didn't express how much better, or whether the tradeoffs are worth it, etc. Thanks for reading. If you haven't already, please leave a comment!
Top comments (6)
This article would benefit from peer review. And appears to be heavy on opinion and hasn't been fact checked given the details I see as incorrect, or possibly presented sub-optimally to support a conclusion (regardless of intent). Given my lack of interest in fixing every problem on the internet, just wanted to mention it as I strongly prefer native Web Components and LitElement with lit-html over the alternatives--in fact I see these as state of the art despite jargon used for or against, based entirely on a length of direct experience with the various options.
Would you like to point out the inaccuracies, or name them at least? I've tried to be objective and accurate in the post. Hinting at errors isn't helpful for me.
This article is really irrelevant. You are comparing a builtin feature of the browser that has a small framework LitElement (~10kb) around it to make easier doing so with ReactJS (109 kb)
It's like saying Math.max(...myArray) is bad and us lodash.max(myArray)
Also you are not using LitElement correctly like using
updated
. You are declaring your function at the top to add more boilerplate to your example while this is not needed at all.Finally... if you want to use styleMap... just add the standalone styleMap library to your project. You don't need to have ReactJS just for that !
This article seems very opinionated and lack of research.
LitElement and ReactJS can't be compared. It's not the same thing. I am developping all my app just with LitElement and I will (if possible) never go back to ReactJS
I wonder why you are comparing poor LitElement to rich React Hooks?
To be fair, you should have compared LitElement + Haunted hooks
The reason I investigated these two in particular was because of my job requesting me to evaluate LitElement. And I hope to help others making the same evaluation.
Agreed, react has had more attention to tooling and DX, so it is richer. It has the benefit of more time and attention, many years more.
The problem is that you are comparing a class component with a functional one (with hooks). This is pretty weird. You might as well have compared React Class Components to Functional React Components with Hooks.
Just try LitElement with haunted (hooks implementation) and compare it to Functional React Components with Hooks.