Write Less Code For Smaller Packages With `pure-lit`
Posted | Reading time: 11 minutes and 30 seconds.
The last few years in the frontend I spend almost entirely on React, with a few trips to the lands of Vue and Angular. When custom components and web components came up, they did look interesting, yet the ecosystem lacked the developer experience that the others would be able to provide.
But lately, I started looking into web components and lit-elements
again and quickly started to appreciate the features that you now get when working with web components.
First, there is the performance. When I create web frontends outside of work, I mostly create things I display on a raspberry with WPE, and raspberries ain’t the quickest computers in the world. Even if it might not typically reach a 30% performance plus compared to react like in above’s article, the raspberry displays feel much more responsive.
Also, comparing it to vanilla javascript, it’s at a state where it provides a lot of conveniences and makes your life easier. Let’s take a look at a first hello-world.ts
example:
import {
LitElement,
customElement,
html,
css,
property
} from "lit-element";
const blockStyle = css`:host { display: block; }`
@customElement("hello-world")
export class HelloWorld extends LitElement {
@property()
who: string = "noone";
static get styles() {
return [blockStyle];
}
render() {
return html`
Hello ${this.who}!
`;
}
}
@customElement("greet-em")
export class GreetEm extends LitElement {
render() {
return html`
<div>
${["george", "john"].map(
n =>
html`
<hello-world who="${n}"></hello-world>
`
)}
</div>
`;
}
}
This example creates two custom elements (greet-em
and hello-world
), that do nothing more than greeting George and John. To use it on a page you’d have to bundle it (i.e. via rollup), and then you can put it in any HTML file like this:
<!DOCTYPE html>
<html>
<head>
<script type="module" src="helloworld.bundled.js"></script>
</head>
<body>
<greet-em>
Please wait while we load the greetings
</greet-em>
</body>
</html>
There are a lot of magical things happening in the background here, and I’m not going into things you have won that you didn’t even know you missed until you had used them, especially scoped javascript/CSS that comes with it. After using it for a little while, things you have to do in other frameworks, like CSS preprocessors, feel like a hack that you don’t want to be bothered with anymore.
The first time you have to integrate your web components with another SPA, the benefit of using web standards will be so explicit that you will probably start thinking twice before creating any other react component that you might want to use outside of a react ecosystem in the future.
However, for my personal preference, lit-element comes with a lot of ceremonies (read classes), and it lacks a few things that I found helpful in the past (hooks).
I’m a developer, so what do I do when I miss something? I build it.
pure-lit
Let’s start by looking at above’s example, and rewrite it with pure-lit
:
import {
html,
css
} from "lit-element";
import {
pureLit
} from "pure-lit"
const blockStyle = css`:host { display: block; }`
pureLit("hello-world",
({who}: { who: string }) => html`Hello ${who}!`,
{
styles: [blockStyle],
defaults: { who: "noone" }
}
);
pureLit("greet-em",
() => html`<div>${["george", "john"].map(n =>
html`<hello-world who=${n}></hello-world>`)}</div>`
)
The first (and most obvious) thing is that we got rid of the classes. With it, we freed ourselves from the requirement of creating the render
method in the class, add {}
and a return
statement, but we can now use arrow functions for one-liners, reducing the amount of code we have to read when first skimming the components.
Another thing is that the registration of the names greet-em
and hello-world
is no longer implicitly required, and added as a decorator (or via a function later), but very explicit and required. You cannot forget creating a name for your component, and you don’t have to think about the name twice: one time for the component, one time for the class.
Overall, while the first example had ~40 LOC, the second has only ~20 LOC. We halved the amount of code required with this very simple example, and it will be more for bigger ones. Actually, it will be so much less code, that with a simple project with a few components, adding this library of ~800bytes (minified) (~400bytes gzipped) will reduce the total size of your package by multiple kilobytes. See the Metrics chapter below for more information.
This is a nice start, but it does lack the one thing we almost always have in our applications: state.
Now, what can we do with that?
State handling with lit-element-state-decoupler and lit-element-effect
In react’s functional components, state handling can be done via hooks. The idea of calling functions that give you access to functions that allow you to update and query state, while leaving the state object immutable, has worked pretty well. Which is why yours truly created two libraries that work together nicely with pure-lit
: lit-element-state-decoupler and lit-element-effect
The state decoupler comes with two important hook functions: useState
, which gives you easy access to an object capturing your state, and useReducer
, a more sophisticated hook that allows you to apply certain actions on your state, thus helping you to separate your business logic (what should happen when) from your display logic (the custom component). Also, it allows you to trigger events from your actions automatically and thus propagating changes to their parent components.
Let’s take a look at a full-blown example of a todo list. It’s a lot of code, but bear with me - we’ll walk through it step by step.
import { html, css } from "lit-element";
import { useState, useReducer } from "lit-element-state-decoupler";
import { pureLit } from "pure-lit";
import { LitElementWithProps } from "pure-lit/types";
import {niceList} from "./styles"
type ListProps = { items: string[] };
const addTodosReducer = (state: string) => ({
update: (payload: string) => payload,
add: () => state,
});
pureLit(
"todo-list",
(element: LitElementWithProps<ListProps>) => html`<ul>
${element.items.map(
(el) => html`<li @click=${() => element.dispatchEvent(new CustomEvent("remove", { detail: el }))}>${el}</li>`
)}
</ul>`,
{
styles: [niceList],
defaults: { items: [] },
}
);
pureLit("todo-add", (element) => {
const { publish, getState } = useReducer(element, addTodosReducer, "", {
dispatchEvent: true,
});
const onComplete = () => getState().length > 0 && (publish("add"), publish("update", ""));
const onUpdate = ({ value }: { value: string }) => publish("update", value);
return html`
<input
type="text"
name="item"
.value="${getState()}"
@input="${(e: InputEvent) => onUpdate(e.target as HTMLInputElement)}"
@keypress="${(e: KeyboardEvent) => e.key === "Enter" && onComplete()}"
placeholder="insert new item"
/>
<button @click=${() => onComplete()}>
Add Item
</button>
`;
});
pureLit("todo-app", (element: LitElementWithProps<any>) => {
const { getState, publish } = useState<string[]>(element, []);
return html`
<div>
<todo-add @add=${(e: CustomEvent<string>) => publish([...getState(), e.detail])}></todo-add>
</div>
<div>
<todo-list
.items=${getState()}
@remove=${(e: CustomEvent<string>) => publish([...getState().filter((el) => el !== e.detail)])}
></todo-list>
</div>
`;
});
Detour: Difference to react hooks
If you are used to react’s hooks, the first difference that you will notice is that the hooks here have a first argument that requires you to pass the element. The reason for that is: testing - by setting them up this way, the hooks were much easier to unit-test, as they can be created independent of outside elements. In strong difference to the react hooks, as the dependency to the element is explicit, the lovely error
"Hooks can only be called inside of the body of a function component"
react might have given you as this requirement is implicit, can be avoided.
Another thing is that these hooks don’t return an array that can be destructured, but rather an object. The reason for that is that they actually return more than just a way to get and set the state. You can read more about this in the documentation.
So let’s take a look what the hooks do. const { getState, publish } = useState<string[]>(element, []);
is creating a state bucket for a string array, with a default value of []
(empty array), and the methods getState
to retrieve the state, and publish
to update the state. The part where this is most helpful is that whenever your call the publish
method, after the state has been updated the function you have defined (and which contains the useState
) will be called again, thus rendering the component again. Note that you will have to create a new state for that to work - if you just pass in the old array with an added item, the component won’t rerender. The logic depends on the fact that the reference of the state object is different.
Our reducer hook is doing more. Looking at the const { publish, getState } = useReducer(element, addTodosReducer, "", { dispatchEvent: true });
we can already see there’s a little more happening. After we add the element, we add a reducer (which takes in updates and handles state changes), followed by our default state (an empty string) and options. The only option we defined is dispatchEvent
. By setting it to true, we are telling the reducer that it should dispatch all actions as custom events from the custom element.
For the component that uses this element, this means that it can subscribe itself to all the actions that are exposed. In our case we are only interested in the add
action, which roughly looks like this <todo-add @add="${(e) => {}}" />
. Of course, we don’t have to do this. Just as well we could have dispatched the event manually in the todo-add component via element.dispatchEvent(new CustomEvent("add", { detail: getState() }))
, but especially for more complex components that represent a full SPA this can make managing the events much easier.
However, there is one use-case we haven’t talked about. A todo app that only works in a single browser tab is probably not really helpful, usually, you’d save the data on a server and load it accordingly. If we add the fetch into our function like this we’ll run into issues:
pureLit("todo-app", (element: LitElementWithProps<any>) => {
const { getState, publish } = useState<string[]>(element, []);
// fetching the stored todos from the backend
fetch("/api/todos")
.then(response => response.json())
.then(publish);
return html`
<div>
<todo-add @add=${(e: CustomEvent<string>) => publish([...getState(), e.detail])}></todo-add>
</div>
<div>
<todo-list
.items=${getState()}
@remove=${(e: CustomEvent<string>) => publish([...getState().filter((el) => el !== e.detail)])}
></todo-list>
</div>
`;
});
If we start the browser, CPU load will increase and your performance will drop. The reason is that we have created a loop - when loading the API and updating the state, the rendering function is called again and the API is fetched. Which updates the state and the rendering function is called again. Over and over again, until your browser or your server collapses.
This is where effects from lit-element-effect come to help us.
It provides us with two more hooks, useOnce
and useEffect
.
useOnce does what the name says - it calls the function only once
pureLit("todo-app", (element: LitElementWithProps<any>) => {
const { getState, publish } = useState<string[]>(element, []);
// fetching the stored todos only once from the backend
useOnce(() =>
fetch("/api/todos")
.then(response => response.json())
.then(publish)
)
return html`
<div>
<todo-add @add=${(e: CustomEvent<string>) => publish([...getState(), e.detail])}></todo-add>
</div>
<div>
<todo-list
.items=${getState()}
@remove=${(e: CustomEvent<string>) => publish([...getState().filter((el) => el !== e.detail)])}
></todo-list>
</div>
`;
});
More often however we might not want to call it only once, but only if something important to it changes. For our todos, we might want to load the data only for a specific user id which we receive via a property. When the id changes, then we would want to trigger the fetch again. With the effect hook, this would look like this:
type Props = { userId : string }
pureLit("todo-app", (element: LitElementWithProps<Props>) => {
const { getState, publish } = useState<string[]>(element, []);
const {userId} = element
// fetching the stored todos only when the userId changes from the backend
useEffect(() =>
fetch(`/api/todos/${userId}`)
.then(response => response.json())
.then(publish)
, [userId])
return html`
<div>
<todo-add @add=${(e: CustomEvent<string>) => publish([...getState(), e.detail])}></todo-add>
</div>
<div>
<todo-list
.items=${getState()}
@remove=${(e: CustomEvent<string>) => publish([...getState().filter((el) => el !== e.detail)])}
></todo-list>
</div>
`;
});
Now we have a very controlled cycle that allows us to control the triggers, and handle the local state.
In a class-based lit-element, we probably would have added this to lifecycle methods. This has the advantage that is very explicit in which part of the lifecycle things are happening. Thus we would have fetched in the connectedCallback
, and then using a mutating property to make sure the application notices if something changes to trigger a rerender.
With our approach however we don’t have to know anything about the lifecycle of the component, and we don’t have to deal with mutating properties that we only need to pass data from one function to another.
Benefits
Using this library comes with a few benefits, of which I want to highlight two:
You write less code, and you create smaller bundles.
Less code
In the first example already we saw that the amount of code is reduced - in our example by 50%. In components where you are asynchronously fetching certain changes, this amount can go up, in very simple components that already had only a render method the number of lines is far less.
Still, less code means you are faster to read the code. And if there’s less to read, you can focus more on what it does.
Smaller footprint
When pulling in three libraries, the last thing that you would expect is that the bundle size is less, but coincidentally it is. For the lit-element-state-decoupler
GitHub site, I have a demo application with multiple different web components (todo app, counter…) that was using class-based lit-elements that I was bundling with rollup.
I did a snapshot on the bundling result when I was using classes and another one after. This is the result:
Before After
┌──────────────────────────────────┐ ┌──────────────────────────────────┐
│ │ │ │
│ Destination: demo.bundled.js │ │ Destination: demo.bundled.js │
│ Bundle Size: 31.79 KB │ │ Bundle Size: 29.8 KB │
│ Minified Size: 31.75 KB │ │ Minified Size: 29.75 KB │
│ Gzipped Size: 8.3 KB │ │ Gzipped Size: 8.11 KB │
│ Brotli size: 7.37 KB │ │ Brotli size: 7.2 KB │
│ │ │ │
└──────────────────────────────────┘ └──────────────────────────────────┘
The bundle/minified size was reduced by 2KB compared to the original one, in the gzipped, we still have a few hundred bytes even though we added a library.
Why?
For one, those libraries are really tiny. pure-git
has 849 bytes
and zero dependencies at the time of writing, gzipped it’s 489 bytes
. For the other two libraries, it’s the same, adding all of them adds 1.5 kb
on your gzipped weight.
On the other hand, a feature that I was using for my convenience had been the typescript decorators to specify the name of the component and the properties. While it makes usage easier, it comes with a polyfill that adds a lot of weight to your page.
In addition, functions can be easier minified than classes. And of course, if you write less code, it’s fewer code that gets in your package.
As a result, adding three little libraries can reduce the overall payload of your application - which for me is probably the nicest outcome of this little exercise.
But why not see for yourself? Here’s a little playground that allows you to get your hands on the code: Go to playground