LitElement is an excellent alternative to build lightweight web applications since it’s based on the Web Components standard, and with the help of TypeScript, we can see more possibilities to build web components faster with a good developer experience.
In this article, I will explain the practical use of @property
and @internalProperty
decorators using a TypeScript implementation.
The Problem
Let's suppose you're building a small application that needs to display a list of Users. When you select any of them, other details should be displayed. In this case, it's the Address information.
Next, let's define the data model and a dataset before implementing the web components using LitElement.
Data Modeling and Dataset
The Data Model
Let's rely on TypeScript interfaces and static typing to have the model ready.
// model.ts
export interface User {
id: number;
name: string;
}
export interface Address {
country: string;
state: string;
city: string;
street: string;
zipCode: number;
}
As you may note, the Address
interface will display a set of attributes that represents a complete address.
The Source of Data
To make the testing easier, let's create a dataset of entities that matches the previous model.
// data.ts
import { Address } from "./model";
import { User } from "./model";
export const users: User[] = [
{
id: 0,
name: "Donald Mayfield"
},
{
id: 1,
name: "Jill J. Fritz"
},
{
id: 2,
name: "Terry Buttram"
}
];
export const address: Address[] = [
{
street: "2180 BELLFLOWER",
country: "USA",
state: "AL",
city: "Madison",
zipCode: 35064
},
{
street: "845 ODOM ROAD, SUITE 200",
country: "USA",
state: "CA",
city: "Los Angeles",
zipCode: 90720
},
{
street: "9025 QUEENS BLVD",
country: "USA",
state: "NY",
city: "Queens",
zipCode: 11355
}
];
The relationship between the users
and address
is simple: The User id
matches with the position of the address
array.
Using LitElement Properties
LitElement provides different ways to manage the properties. As the documentation says:
LitElement manages your declared properties and their corresponding attributes.
This can be done using either a static properties
field or using decorators. Let's take the TypeScript-way using the power of decorators.
We'll understand the "properties management" better in the practice through the next sections.
Creating the MainViewer Container
Let's create a class to contain our first component. In this case, we'll define a container component that will be in charge of displaying both the list of users and their details.
import {
LitElement,
html,
customElement,
css,
internalProperty
} from "lit-element";
import { users } from "./data";
import { User } from "./model";
@customElement("main-viewer")
class MainViewer extends LitElement {
static styles = css`
:host {
display: block;
}
`;
users: User[] = [];
userId?: number;
constructor() {
super();
}
render() {
return html`
<div>
<h1>User Address Viewer</h1>
<span>Select a User to see the Address:</span>
<ul>
${this.users.map(
user => html`
<li>
<a href="#" @click="${() => this.viewAddress(user.id)}"
>${user.name}</a
>
</li>
`
)}
</ul>
</div>
`;
}
async connectedCallback() {
super.connectedCallback();
this.users = await this.getUsers();
}
private getUsers() {
// in the real-world you'll get the data from a service, file, etc.
return new Promise<User[]>((resolve, reject) => resolve(users));
}
private viewAddress(id: number) {
this.userId = id;
}
}
Let's explain what is happening in this component so far:
- The
static styles
attribute defines the styles for the component using a tagged template literal (css
). - It defines two class properties:
users
to "store" the set of Users to be displayed in the main list. Also, theuserId
will contain a reference of the selected User identifier (this value will be changed every time you select a different user). - The
render
method returns the HTML content through a template literal (html
). This function will be called any time a component property changes. - The
connectedCallback
function makes a call togetUsers
in order to get the initial data when the component is added to the document's DOM. - The
getUsers
function should perform an asynchronous call (in a real-world scenario) to retrieve the data. - The
viewAddress
function receives anid
of the object selected once the user performs a click action over the link. Then, the class propertyuserId
will be updated.
The previous code is perfectly fine. However, it won't display any data yet! We'll only see the title and a blank section on the page and no list of users rendered.
Using the @internalProperty Decorator
In order to fix this rendering issue, we should make sure to trigger an update cycle for the web component.
Think for a while about when the update cycle should be performed: every time you change the list of users
! However, this cannot be done every time you update any variable or attribute from your class. Instead, you should mention which attributes are "keys" to perform the rendering operation or keep your component updated. We'll call LitElement properties to these key attributes.
Once you have identified them, we'll need to declare these attributes as properties:
// main-viewer.ts
@customElement("main-viewer")
class MainViewer extends LitElement {
// ...
@internalProperty() users: User[] = [];
@internalProperty() userId?: number;
constructor() {
super();
}
// ...
}
Why use @internalProperty
in this case?
- For this
MainViewer
component, we don't need to reference eitherusers
oruserId
from outside the component. - From the previous point, we can consider
users
anduserId
attributes as private or protected.
In other words, LitElement observes and "reacts" to these property changes so the template gets rendered/updated automatically.
Using the @property Decorator
Creating the AddressViewer Component
Before creating the child component, let's think about how we are going to use it from the container. In terms of web components, let's suppose we'll need the following element and attribute:
<address-viewer .userId=${userId}></address-viewer>
That means the new address-viewer
component will need to "receive" the userId
value to be able to retrieve the respective Address. The .userId=${userId}
notation applies a one-way data binding to a property, meaning the AddressViewer
component will have the userId
as a public property.
@property vs @internalProperty decorators
Let's take the previous consideration into account and create a new file address-viewer.ts
:
import {
LitElement,
html,
property,
customElement,
css,
internalProperty
} from "lit-element";
import { address } from "./data";
import { Address } from "./model";
@customElement("address-viewer")
class AddressViewer extends LitElement {
static styles = css`
:host {
display: block;
}
table {
border-collapse: collapse;
width: 100%;
}
td,
th {
border: 1px solid gray;
text-align: left;
padding: 5px;
}
`;
@property({ type: Number }) userId: number;
@internalProperty() userAddress?: Address;
constructor() {
super();
}
render() {
if (this.userAddress === undefined) {
return html``;
}
return html`
<table>
<tr>
<th>Country</th>
<th>State</th>
<th>City</th>
<th>Street</th>
<th>Zip Code</th>
</tr>
<tr>
<td>${this.userAddress.country}</td>
<td>${this.userAddress.state}</td>
<td>${this.userAddress.city}</td>
<td>${this.userAddress.street}</td>
<td>${this.userAddress.zipCode}</td>
</tr>
</table>
`;
}
update(changedProperties: Map<string, unknown>) {
if (changedProperties.has("userId")) {
const oldValue = changedProperties.get("userId") as number;
console.log("userId updated, newVal", this.userId, "oldVal", oldValue);
this.loadAddress(this.userId);
}
super.update(changedProperties);
}
private async loadAddress(id: number) {
this.userAddress = await this.getAddress(id);
}
private getAddress(id: number) {
return new Promise<Address>((resolve, reject) => resolve(address[id]));
}
}
Again, let's take a closer look at the previous source code:
- The
userId
attribute is defined as a public property for the component using@property
decorator. - The
userAddress
property doesn't need to be public property for the component. Instead, it's defined using@internalProperty
decorator to trigger the update cycle once it gets changed.- Initially, It's
undefined
- Initially, It's
- The
render
method returns the HTML content to be rendered every time a property is changed.- Note the function will return a meaningful template only once the
userAddress
contains the required object.
- Note the function will return a meaningful template only once the
- The
update
function reflects property values and calls therender
function.- It receives a
Map
with the properties that have been changed. - It verifies if the
userId
property has been changed, and then performs a call toloadAddress
. - Whenever you override this method, you'll need to call
super.update()
to render your template.
- It receives a
- The
loadAddress
function takes the new value foruserId
and calls the utility function to retrieve the Address object.
The brand-new AddressViewer
component is ready, and we'll need to use it as a child component in the main container. Let's update the render
function as follows:
// main-viewer.ts
// Let's import the <address-viewer> definition
import "./address-viewer";
@customElement("main-viewer")
class MainViewer extends LitElement {
//...
render() {
return html`
<div>
<h1>User Address Viewer</h1>
<span>Select a User to see the Address:</span>
<ul>
${this.users.map(
user => html`
<li>
<a href="#" @click="${() => this.viewAddress(user.id)}"
>${user.name}</a
>
</li>
`
)}
</ul>
<address-viewer .userId=${this.userId}></address-viewer>
</div>
`;
}
// ...
}
Again, pay attention to the one-way data binding .userId=${this.userId}
that will trigger a rendering operation over the <address-viewer>
component whenever the userId
gets changed. This is magic, right?
Live Demo
Want to play around with this code? Open the Stackblitz editor:
Feel free to reach out on Twitter if you have any questions. Follow me on GitHub to see more about my work.
This Dot Labs is a modern web consultancy focused on helping companies realize their digital transformation efforts. For expert architectural guidance, training, or consulting in React, Angular, Vue, Web Components, GraphQL, Node, Bazel, or Polymer, visit thisdotlabs.com.
This Dot Media is focused on creating an inclusive and educational web for all. We keep you up to date with advancements in the modern web through events, podcasts, and free content. To learn, visit thisdot.co.
Top comments (0)