Web Components (Part 4): Lit for Angular Developers

In the previous article of this blog post series, we introduced the lightweight Web Component library Lit. Although Lit is widely used, it is still not as popular as mainstream JavaScript frameworks among frontend engineers. But we think you should give it a try. So in this fourth article, we will look at the various concepts and features of Lit through the eyes of an Angular developer.

Contents

Basic concepts

We compare the basic concepts of Angular and Lit side-by-side. On the left, the Angular way of building UI components is described and on the right, you can learn how to achieve the same with Lit.

Components & basic rendering

Let’s look at a simple Angular component that renders some markup:

@Component({
  selector: "my-component",
  template: `
    <p>Hello world!<p>
  `,
  styles: [`p { color: blue }`],
})
export class MyComponent {}

It consists of a component class (a controller) with a declaratively defined template and associated styles.

In Lit, the controller class implements a render function to render the UI as a function of the component state (similar to frameworks like React), and defines its styles in a static class field:

@customElement("my-component")
export class MyComponent extends LitElement {
  static styles = css`p { color: red }`;

  render() {
    return html`<p>Hello world!</p>`;
  }
}

The html`` tagged template literal (a native JavaScript construct) from lit-html is used to render the template. While lit-html is part of Lit and can be imported from the lit package, it is a standalone template system that could be used independently from Lit.

Similar to html``, the css`` tagged template literal allows us to interpolate dynamic values into the style sheet definitions as well.

Template interpolation

To render a dynamic value with Angular, you can create a data binding by referencing any public member of the component class in the template:

@Component({
  selector: "my-component",
  template: `
    <p>Hello {{ name }}!<p>
  `,
})
export class MyComponent {
  name = "Jane";
}

With Lit, you can simply reference any value in the scope of the function body using plain JavaScript rules:

@customElement("my-component")
export class MyComponent extends LitElement {
  name = "Jane";
  render() {
    return html`<p>Hello ${this.name}!</p>`;
  }
}

Inputs vs. reactive properties

Angular supports inputs that update the UI if their value changes:

@Component({
  selector: "my-component",
  template: `
    <p>Hello {{ name }}!<p>
  `,
})
export class MyComponent {
  @Input()
  name = "unknown";
}

When used in a template, the component’s input value can be assigned using the bracket syntax:

<my-component [name]="'Jane'"></my-component>

As Lit builds on web standards, it uses the concepts already provided by these standards. In DOM context, an element can have properties and attributes. Lit supports so-called reactive properties that work similar to an Angular input:

@customElement("my-component")
export class MyComponent extends LitElement {
  @property()
  name = "unknown";

  render() {
    return html`<p>Hello ${this.name}!</p>`;
  }
}

The usage in markup is as expected:

<my-component name="Jane"></my-component>

For Lit’s reactive properties, an update is scheduled (re-rendering) when a new value is set (e.g. this.name = "John"). You can also get or set the value of a reactive property if you have a reference of the DOM element:

const element = document.querySelector("my-element");
console.log(element.name); // "Jane"
element.name = "John"; // Will update the UI
element.setAttribute("Jeanne"); // Will update the UI as well

As you can see, a reactive property is associated with an attribute per default. Hence it will also render the value if set via attribute in markup or via the element’s setAttribute function.

A word on attribute conversion

Since an attribute’s value can only be a string, a conversion is necessary for any other data types. Lit supports a few default converters or allows the implementation of custom converters. Here is an example with the default Number converter that will convert the attribute’s string value into a number value:

@property({ type: Number })
age = 27;

So this will work:

element.setAttribute("age", "42");
console.log(element.age); // 42
console.log(typeof element.age); // "number"

Finally, Lit allows us to set property values or boolean attributes declaratively in templates using a dedicated syntax:

Set attribute value and property value via converter:
<my-component age="27"></my-component>

Set property value:
<input .value=${value}>

Set boolean attribute:
<div ?hidden=${!show}></div>

Internal state

There are cases where values are only used internally, and are not part of a component’s public API. With Angular, we can just omit the @Input() decorator and the UI will still be re-rendered, when the variable is updated:

@Component({
  selector: "my-component",
  template: `
    <button (click)="toggle()">Toggle</button>
    <p *ngIf="expanded">Hello world!<p>
  `,
})
export class MyComponent {
  expanded = false;

  toggle() {
    this.expanded = !expanded;
  }
}

With Lit, if you just create a property and update its value, the UI won’t be re-rendered. But you can create an internal property that behaves just like a reactive property. This is called internal state and can be annotated as follows:

@customElement('my-component')
export class MyComponent extends LitElement {
  @state()
  private expanded = false;

  private toggle() {
    this.expanded = !this.expanded;
  }
  
  render() {
    return html`
      <button @click=${() => this.toggle()}>Toggle</button>
      ${this.expanded ? html`<p>Hello world!</p>` : ""}
    `;
  }
}

Declarative event handlers

You might have noticed in the previous example, that we used Angular’s declarative event binding with the parentheses syntax:

<button (click)="toggle()">Toggle</button>

Lit also supports declarative event handlers with a slightly different syntax:

<button @click=${() => this.toggle()}>Toggle</button>

The advantage of Lit’s declarative event handlers is that they receive a plain JavaScript function and you can use full JavaScript syntax, whereas with Angular, there are some limitations to what the event binding expression supports.

Outputs vs. DOM events

In Angular, a component may define outputs that emit events when something happens:

@Component({
  selector: 'my-counter',
  template: `
    <p>Count: {{ count }}<p>
    <button (click)="handleIncrement()">+</button>
  `,
})
export class MyCounter {
  @Output()
  increment = new EventEmitter<number>();

  count = 0

  handleIncrement() {
    this.count += 1;
    this.increment.emit(this.count);
  }
}

We can then subscribe to these events using the declarative binding syntax:

@Component({
  template: `
    <my-counter (increment)="handleIncrement($event)"></my-counter>
  `,
})
export class MyComponent {
  handleIncrement(count: number) {
    console.log('Incremented:', count);
  }
}

With Lit we can dispatch regular DOM events to notify if something happens, either by using an existing event type or a CustomEvent:

@customElement('my-counter')
export class MyCounter extends LitElement {
  @state()
  count = 0;
  
  private increment() {
    this.count += 1;
    this.dispatchEvent(new CustomEvent('increment', { bubbles: true, composed: true, detail: this.count }))
  }

  render() {
    return html`
      <p>Count: ${this.count}</p>
      <button @click=${this.increment.bind(this)}>+</button>
    `;
  }
}

The usage then is pretty close to Angular’s:

export class MyComponent extends LitElement {
  private handleIncrement(event: CustomEvent<number>) {
    const count = event.detail;
    console.log('handleIncrement', count); 
  }

  render() {
    return html`
      <my-counter @increment=${this.handleIncrement.bind(this)}></my-counter>
    `;
  }
}

If you dispatch events, it is important to enable the Event.bubbles option, to allow event delegation. It is also important to enable the Event.composed option for the event to pass the shadow boundary. But more on this topic in the next section.

Encapsulation

Angular’s default encapsulation mode is „emulated“. It annotates the component’s style rules with a component-specific identifier, such that they only apply to the component’s internal elements:

p[_ngcontent-ng-c3264886457] {
  color: red;
}

Apparently this does not prevent global page styles to apply to a component’s internal elements.

Alternatively, you can explicitely switch to an encapsulation mode that enables shadow DOM for the component:

@Component({
  selector: "my-component",
  template: `
    <p>Hello world!<p>
  `,
  styles: [`p { color: blue }`],
  encapsulation: ViewEncapsulation.ShadowDom,
})
export class MyComponent {}

This way, no styles are leaking in or out of the shadow boundary.

Lit uses shadow DOM encapsulation per default for every component. This inherently prevents any leaking of styles in either direction and hides the internal elements in APIs like document.querySelector. You can use the :host or ::slotted selectors to style the component element itself or slotted elements.

Concerning events, all browser-dispatched UI events (like the click events) are composed, meaning they will pass the shadow boundary. This is not the case for custom events, where the Event.composed option has to be enabled explicitly, or they won’t pass the shadow boundary.

Be aware, that the shadow DOM sure has dragons. So if you wish to create a Lit component without encapsulation, you can achieve this as such:

export class MyComponent extends LitElement {
  // ...
  createRenderRoot() { return this; }
}

Lifecycle callbacks

Angular supports various lifecycle hooks to react to certain events of a component’s lifecycle:

export class MyComponent {
  constructor() {
    // Component instantiated
  }
  ngOnInit() {
    // Inputs set
  }
  ngAfterViewInit() {
    // DOM available
  }
  ngOnDestroy() {
    // Cleanup
  }

  ngOnChanges(changes: SimpleChanges) {
    // Some input changed
  }
}

As Lit components are just custom elements, you can implement the standard lifecycle callbacks:

export class MyComponent extends LitElement {
  constructor() {
    super();
    // Component instantiated
  }
  connectedCallback() {
    // DOM available
  }
  disconnectedCallback() {
    // Cleanup
  }

  static get observedAttributes() {
    return ['x', 'y'];
  }
  attributeChangedCallback(
    name, oldValue, newValue) {
    // Some attribute changed
  }
}

In addition to the custom elements callbacks, Lit supports callbacks around its reactive update cycle such as willUpdate(), update() or updated(), to name a few.

Template directives

In Angular templates you can work with expressions that are JavaScript-alike, but come with special rules. It also provides some built-in structural directives, that can be used declaratively.

When rendering Lit templates, you can just use JavaScript/TypeScript expressions. In addition to this, Lit comes with some built-in directives that can be used imperatively.

Conditions

You can add or remove elements based on a condition using the *ngIf directive:

<button *ngIf="show">Click me</button>

The directive also supports an else branch:

<button *ngIf="show; else elseBlock">
  Click me
</button>
<ng-template #elseBlock>
  No access
</ng-template>

To add/remove elements, you can basically use JavaScript if or ternary expressions:

this.show ? html`<button>Click me</button>` : null

Likewise, else branches are easy:

this.show ? html`<button>Click me</button>` : "No access"

Alternatively you can use the built-in when directive:

when(
  this.show,
  () => html`<button>Click me</button>`,
  () => html`No access`,
)

Loops

In Angular templates you can use the *ngFor directive to loop over an array:

<ul>
  <li *ngFor="let item of items">
    {{ item }}
  </li>
</ul>

For a more efficient handling of the DOM elements (e.g. reusing them if the order changes instead of re-rendering the whole list), you can use a trackBy function like trackByItems(item) { return item.id }:

<ul>
  <li *ngFor="let item of items; trackBy: trackByItems">
    ({{item.id}}) {{item.name}}
  </li>
</ul>

In Lit templates, you can just use the JavaScript array’s map function:

html`<ul>
  ${items.map((item) => html`<li>${item}</li>`)}
</ul>`

Or, similar to the when directive, you could also use the built-in map directive.

Similar to Angular’s trackBy, you can use Lit’s repeat directive. Although that is initially slower than map, it then reuses and updates the existing DOM nodes, which provides DOM stability and is perfect for large lists or complex items:

html`<ul>
  ${repeat(items, (item) => item.id,
      (item) => html`<li>(${item.id}) ${item.name}</li>`}
</ul>`

And more…

There is Angular’s ngSwitch directive and Lit’s counterpart, the choose directive. Or Angular’s ngClass or ngStyle directives and Lit’s classMap and styleMap helpers. But Lit also includes a few more interesting directives, which can be handy for some use cases.

Conclusion

We’ve learned that, for working with UI components, many concepts in Angular and Lit are comparable. Instead of inputs we can use properties and attributes, instead of outputs we can use DOM events. Lit strongly relies on web standards, so it integrates many constructs we already know. However, when it comes to templating and rendering, there are notable differences. In contrast with Angular’s declarative approach, its bulky change detection, and its dirty checking machinery, Lit uses a very efficient templating and rendering mechanism, that embraces plain JavaScript and only updates the parts that have changed.

There are also many aspects we haven’t touched. For instance, there are features like content projection vs. slots, @ViewChild(ren) vs. @query*, Angular i18n vs. @lit/localize and so on. Lit’s reactive controllers, class mixins and @lit/context replace Angular’s services; and with @lit/task we have a great way of dealing with asynchrous results.

Nonetheless, Angular is a frontend framework that solves almost every facet – as opposed to Lit, which is only a small libary. So for things like client-side routing you have to reach out for a framework-agnostic router implementation such as the @vaadin/router.

Coming up

In the next article of this blog post series on Web Components, we will look at a concrete project using Web Components with Lit – the Puzzle Shell, a component library for internal applications.

All articles of the series:

Kommentare sind geschlossen.