DEV Community

loading...
Cover image for Why LitElement isn't as good as React

Why LitElement isn't as good as React

tsweb profile image Tias Updated on ・7 min read

^ 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;
...
Enter fullscreen mode Exit fullscreen mode

** 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;
...
Enter fullscreen mode Exit fullscreen mode

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;
Enter fullscreen mode Exit fullscreen mode

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);
...
Enter fullscreen mode Exit fullscreen mode

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;
    }
...
Enter fullscreen mode Exit fullscreen mode

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!
...
Enter fullscreen mode Exit fullscreen mode

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([ ... ], () => () => {
    ...
Enter fullscreen mode Exit fullscreen mode

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.

See runem/lit-analyzer#149

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);
    }
}
Enter fullscreen mode Exit fullscreen mode

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} />
}
Enter fullscreen mode Exit fullscreen mode

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`,
    })}
Enter fullscreen mode Exit fullscreen mode

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`;
Enter fullscreen mode Exit fullscreen mode

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`,
})}
Enter fullscreen mode Exit fullscreen mode

React

style={{
    top,
    right,
    bottom,
    left,
}}
Enter fullscreen mode Exit fullscreen mode

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!

Discussion (5)

pic
Editor guide
Collapse
jimmont profile image
Jim Montgomery • Edited

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.

Collapse
tsweb profile image
Tias Author

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.

Collapse
gormonn profile image
Dmitriy Aleksandrovich

I wonder why you are comparing poor LitElement to rich React Hooks?

To be fair, you should have compared LitElement + Haunted hooks

Collapse
tsweb profile image
Tias Author

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.

Collapse
gormonn profile image
Dmitriy Aleksandrovich

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.