Lit MicroProject
I wanted to pick up and play with a UI framework other than React because I’d been writing apps for a while now and have felt a bit stale (although, I do plan on investigating Server Side Rendering sometime). So I started looking into Lit. This is a relatively new framework built onto the web component standard. The first time I saw this was as an optional way for developing a standard design system, so I’ve had it on the list of things to investigate.
This is an interesting framework for building UIs because it combines the three core HTML specifications behind Web Components so that it doesn’t need custom web framework logic or concepts (i.e. hook logic or JSX) to write modern UI interfaces.
- Custom Elements
- Create custom tags with their own lifecycles i.e. calls functions on mount, dismount, update. - Shadow DOM
- scopes your styles to the specific component. - HTML Templates
- You can do custom logic in your HTML i.e. show something if state is true.
- Also you can pass in another component and display it inside your web component i.e. if you have a Jumbotron component and you had some custom content that is different per page, you can slot that in.
This allows developers to create components that previously you needed a web framework like React, Vue, Svelte or Angular to do.
The scope of this microproject (which still took me a long time to do) was to build a client side Todo app. I don’t want to have any future uses to this app, like link it up to a DB, so all the state can live on the front-end.
You can find it here: https://github.com/Recelis/modern-dev-playground/tree/main/lit-app
So let’s extract the core ideas out of this project. Starting with the root file.
@customElement("lit-index")
export class LitIndex extends LitElement {
static styles = css`
.todo-app {
width: 100%;
height: 100%;
margin: 0 auto;
display: flex;
flex-direction: column;
align-items: center;
}
`;
@state()
_todos: Todo[] = [];
connectedCallback() {
super.connectedCallback();
window.addEventListener("addTodo", this._addTodo);
window.addEventListener("updateTodo", (e: Event) => {
const customEvent = e as CustomEvent<Todo>;
this._updateTodo(customEvent.detail);
});
window.addEventListener("removeTodo", (e: Event) => {
const customEvent = e as CustomEvent<RemoveTodo>;
this._removeTodo(customEvent.detail);
});
}
disconnectedCallback() {
window.removeEventListener("addTodo", this._addTodo);
window.removeEventListener("updateTodo", (e: Event) => {
const customEvent = e as CustomEvent<Todo>;
this._updateTodo(customEvent.detail);
});
}
private _addTodo = () => {
console.log("todo button pressed");
this._todos = [...this._todos, { id: uuidv4(), text: "", done: false }];
};
private _updateTodo = (updateTodo: Todo) => {
this._todos = this._todos.map(todo => todo.id === updateTodo.id ? updateTodo : todo);
};
private _removeTodo = (removeTodo: RemoveTodo) => {
this._todos = this._todos.filter(todo => todo.id !== removeTodo.id);
}
render() {
return html`<div class="todo-app">
<h1>Lit To do App</h1>
<add-todo></add-todo>
<todo-list .todoList=${this._todos}></todo-list>
</div>`;
}
}Custom Elements
@customElement("lit-index")Add this decorator on top of the element to make it an HTML custom element. This is just syntactic sugar for the equivalent.
customElements.define('lit-index', MyElement);Where customElements is part of the global object. This does mean that the name of the custom element needs to be unique. For non-browser environments like test environments, you do need to ensure that it is not defined multiple times so it’s good to add a guard around this.
if (!customElements.get('lit-index')) {
customElements.define('lit-index', LitIndex);
}You’d also need to add the Lit component into your index.html file.
<script type="module" src="/src/lit-index.ts"></script>Lit Class
The next line is to extend the component from LitElement. This actually then extends ReactiveElement which then extends HTMLElement and implements the ReactiveControllerHost interface.
LitElement gives you access to protected methods createRenderRoot , update , connectedCallback , disconnectedCallback , and render . The connectedCallback is like an onMount function where you add your event listeners. The disconnectedCallback is the place to remove your listeners. Calling update triggers an update which isn’t really necessary. I don’t need to go too deep into how the library is created so let’s carry on.
Templating
The templating language is pretty similar to others like React so I won’t go into it too much but you do need to wrap everything in a html string.
render() {
return html`<div class="todo-app">
<h1>Lit To do App</h1>
<add-todo></add-todo>
<todo-list .todoList=${this._todos}></todo-list>
</div>`;
}State
The next thing to look at is internal state.
@state()
_todos: Todo[] = [];Modifying the value will trigger a rerender.
This only does a check if the reference has changed. So it is important to reassign the objects when changing any fields within them.
this._todos = [...this._todos, { id: uuidv4(), text: "", done: false }];You can overwrite the hasChanged to detect a change to trigger a rerender.
@property({
hasChanged(newVal: string, oldVal: string) {
return newVal?.toLowerCase() !== oldVal?.toLowerCase();
}
})
myProp: string | undefined;Passing in properties done through the @property decorator.
// Within the Lit element
@property({ type: Array }) todoList: Todo[] = [];From the parent:
<todo-list .todoList=${this._todos}></todo-list>Passing the value down from the parent relies on HTML attributes. Lit will bind the attribute to the property so that whenever the attribute changes, the property does too.
Unlike React, @properties are not immutable. So you can change them internally, and if you have reflection turned on, it will be reflected back from the property to the attribute.
// inside the element
@property({type: Boolean, reflect: true})
active: boolean = false;
// later in an event handler
active = true;// from the parent
<my-element active/>Bindings
You notice that the todoList has a dot in front of it. This is a property binding https://lit.dev/articles/lit-cheat-sheet/#attribute-and-property-expressions-(binding-syntax) which allows values to be written directly to the DOM property rather than as an attribute . It is used as for non-string values like objects, arrays, or numbers.
Get Jacky’s stories in your inbox
Join Medium for free to get updates from this writer.
If you pass in without this dot, it is called attribute binding then it would coerce it into a string. It is equivalent to
element.setAttribute('value', '42');The last binding is the Boolean attribute binding which allows you to set booleans to the attributes.
html`<my-element ?open=${true}></my-element>`This is equivalent to:
true → element.setAttribute('open', '')
false → element.removeAttribute('open')Removing is a special case because HTML does not deal with booleans.
The last thing I wanted to mention was catching events propagated from child elements. So we have a handler in todo-listitem.ts which calls:
this.dispatchEvent(new CustomEvent("updateTodo", options));This will then propagate up the ancestor chain until it gets received by an event listener in the connectedCallback in lit-index.ts . The good thing about this is that it doesn’t need to be caught by the direct parent which is less cumbersome compared to React where you have to pass down functions across all descendent components until you reach the component that needs to use that function.
connectedCallback() {
super.connectedCallback();
window.addEventListener("addTodo", this._addTodo);
window.addEventListener("updateTodo", (e: Event) => {
const customEvent = e as CustomEvent<Todo>;
this._updateTodo(customEvent.detail);
});
window.addEventListener("removeTodo", (e: Event) => {
const customEvent = e as CustomEvent<RemoveTodo>;
this._removeTodo(customEvent.detail);
});
}Seting up Testing
Because I don’t consider ‘knowing’ something until I can write automated tests that it works. However, I have to admit that I did vibe code the tests but a lot of manual debugging was needed to get them running.
The Lit documentation advises you to use the web-test-runner.
"@open-wc/testing": "^4.0.0",
"@testing-library/dom": "^10.4.1",
"@types/mocha": "^10.0.10",
"@web/dev-server-esbuild": "^1.0.4",
"@web/dev-server-legacy": "^2.1.1",
"@web/test-runner": "^0.20.2",Essentially, need to set up tsconfig .
Then web-test-runner.config.js
export default {
files: ['src/**/*.test.ts'],
nodeResolve: true,
testFramework: {
config: {
timeout: '5000', // can be modified
},
},
plugins: [
esbuildPlugin({ ts: true, target: 'auto', tsconfig: 'tsconfig.json' }),
],
};This specifically needs the tsconfig field pointing to the tsconfig.json file otherwise, it wouldn’t know that the imported web components are Lit components.
I debugged with this log.
// false when no tsconfig field in config!
console.log('Is LitElement?', el instanceof LitElement);Writing automated tests is pretty standard with your describe and it syntax.
describe('TodoListItem', () => {
... it('renders todo text', async () => {Testing inside an element, given a parent:
const container = el.shadowRoot?.querySelector('.todo-listitem');
expect(container).to.exist;
const item = container?.querySelector('.item');I also needed to clean the fixtures between each test.
import { expect, fixture, fixtureCleanup } from '@open-wc/testing';
...beforeEach(() => {
fixtureCleanup();
});To test that an event was created, we can use this pattern.
let capturedEvent: CustomEvent | null = null;
el.addEventListener('updateTodo', (e: Event) => {
capturedEvent = e as CustomEvent;
});
// lifecycle method to wait until all updates have been completed
await el.updateComplete;expect(capturedEvent).to.exist;
expect(capturedEvent?.detail.id).to.equal('1');Triggering a change in the test
We can trigger changes in inputs by dispatching events from those components.
input.value = 'Updated todo';
input.dispatchEvent(new Event('change'));expect(input.value).to.equal('Updated todo');It is important to remember that a unit test only checks within the original scope, if you are testing the list item then you only detect the events from that event.
For button click, there already is a click() function.
const button = el.shadowRoot?.querySelector('button') as HTMLButtonElement;
button?.click();await el.updateComplete;expect(capturedEvent).to.exist;
Dispatching event in Tests
So if you want to test that your component reacts to events propagated to it.
window.dispatchEvent(new CustomEvent('addTodo'));await el.updateComplete;
expect(el._todos.length).to.equal(1);
expect(el._todos[0].text).to.equal('');
expect(el._todos[0].done).to.be.false;So that is basically all the findings that I had from my microproject! The docs does have a lot more stuff but I’m just trying to understand the basics of Lit. Would I use it over something like React, probably not because the React ecosystem is extensive, despite Lit feeling like a nicer UI framework, but it is something to keep an eye on over the others.





