Why LitElement isn't as good as React
Tags: litelement, react, webcomponents, typescript
Published: 2021-03-10 22:25:11

> ^ 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:

```ts
link: (scope) => {
    const closeDelay = isDefined(scope.closeDelay) ? scope.closeDelay : 300;
...
```

** WCs don't have to be classes. See [matthewp/haunted](https://github.com/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

```ts
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.

```ts
// 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:

```ts
...
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
```ts
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.

```ts
...
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.

```ts
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](https://github.com/runem/lit-analyzer/issues/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](https://github.com/runem/lit-analyzer/issues/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
```ts
@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
```tsx
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](https://twitter.com/youyuxi/status/1363176352258662402). At its core, React is a [state management library](https://twitter.com/_marbiano/status/1358859931500376064). DOM is just a render target. This is why React can be used to write native apps [and other things](https://twitter.com/dabit3/status/1358860364784545794). Allowing components to represent things, not just DOM elements, allows for [more expressive APIs](https://twitter.com/Awnton/status/1361732510409981955).

## 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.

```ts
    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)
```ts
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
```ts
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
```ts
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!