Skip to main content

Introduction to Component

To explore the possibilities of @lirx/dom, we'll create a simple todo list:

This tutorial will cover the most essential parts of this framework through a comprehensive and generic example. After following it, you'll be able to create your own components and start to develop great web applications.

Everything is available in this repository. I encourage you to clone it, as it is a very good starting point.

Our first component

First, let's define what is a component:

A component is the most basic UI building block of a web app. It is equivalent to an Element, but it has its own template, style, properties, and interactions. It is not provided natively by the browser, but constructed by ourselves or from an external library using @lirx/dom.

An application is simply formed from a tree of components, starting from a "root" component.

Files structure

Just like Angular, @lirx/dom follows the MVC pattern. This means that a component is usually constituted of 3 files:

An html file whose name usually ends with .component.html. This file contains the template of the component, which extends the HTML syntax with custom attributes and tag names. It is used to generate and manipulate the DOM.

A scss file whose name usually ends with .component.scss. This file contains the style of the component, and may contain css variables to allow the parent elements to easily modify the style of this component. .css files are supported too, but .scss is strongly recommended, as it is much more concise and practical.

A typescript file whose name usually ends with .component.ts. This file defines the component itself, imports the template and styles, registers interactions from the user or other events, and sets the values required by the template. .js files are supported too, but we encourage the usage of typescript.

We'll focus now on the "rows" of our todo list. We'll call this component todo-list-item. It's simply a container with a message field and a remove button:

So let's start by creating a folder for this component:

mkdir todo-list-item
cd todo-list-item

Then we have to create the template, style, and definition of this component (in three separate files):

touch todo-list-item.component.html todo-list-item.component.scss todo-list-item.component.ts

We end up with this files' structure:

Template

The template of our component will use a custom HTML syntax called reactive-html. It is based on custom attributes and tags, is compatible with the standard HTML, and allows us to easily manipulate the DOM through Observables.

We'll start by editing the file todo-list-item.component.html:

todo-list-item/todo-list-item.component.html
<div class="message">
{{ $.message$ }}
</div>
<button
class="remove-button"
(click)="$.$onClickRemoveButton"
>
Remove
</button>

Every template receives a local $ variable. This variable contains the data used by the template, and they will be reflected on the DOM. We'll see later that the content of $ is defined into the .ts file (here todo-list-item.component.ts).

The first block creates a <div> those content is a Text node updated each time $.message$ changes. Such a node is created by putting an Observable in double curly brackets, {{}} (documentation).

<div class="message">
{{ $.message$ }}
</div>

The second block creates a <button> with a click EventLister, whose events are sent to the Observer $.$onClickRemoveButton. This is achieved through the custom attribute (click) (documentation).

<button
class="remove-button"
(click)="$.$onClickRemoveButton"
>
Remove
</button>

So, when the user will click on this button, it will call the function $.$onClickRemoveButton.

note

@lirx/dom takes care to subscribe/unsubscribe to the Observables when the nodes are connected or leave to the DOM. This guaranties an optimal usage of the resources.

Style

A component is nothing without style, so let's add some to it:

todo-list-item/todo-list-item.component.scss
:host {
display: flex;
align-items: center;
justify-content: space-between;
gap: 5px;
padding: 8px;
border-radius: 4px;
border: 1px solid #e1e1e1;
background-color: #fafafa;

& > .remove-button {
padding: 0 10px;
background-color: #ff6060;
border-radius: 4px;
border: 0;
text-transform: uppercase;
line-height: 30px;
color: #fff;
cursor: pointer;
}
}

The css of the component is the same as one for a custom element. It supports :host and :host-context to select the element.

info

By default, a component does not use the ShadowDOM, so the css is not purely scoped and strictly limited to this component. Yet, it's possible to enable the ShadowDOM per component with the option mode: 'shadow'.

In consequence, by default, without attention, some style may leak on the descendants or on the global scope. This would result in unwanted behaviour, so we encourage the developers to follow these rules:

  • always put the style and selectors into an :host. If we write a selector outside an :host, it won't be scoped to the component, and it will apply globally. Sometimes it may be useful, if, for example, we want to style some elements outside our component, but it must be used with extreme precautions.
  • use the @scope rule, or use the > (child combinator) selector. Else, we may select deep children, and apply unwanted style to them.
  • always set a display to the component.

We chose to remove by default the constraints imposed by the shadowDOM, as we want the developers to be able to style external components (coming from an external library), which is common, and especially useful on unmaintained ones. However, with great power comes great responsibility, so it's your role to take care not to leak any css outside your component's scope.

Component

The component itself is defined into a typescript file (in our example: todo-list-item.component.ts).

Import the template and style

We'll start by importing the template and the style of the component:

// @ts-ignore
import html from './todo-list-item.component.html?raw';
// @ts-ignore
import style from './todo-list-item.component.scss?inline';

Using vitejs or the aot compiler, we may import various file contents using the esm syntax.

Define the component's inputs and outputs

After importing our template and style(s), we have to define the inputs and the outputs of the component:

  • an Input is similar to the property of an element like input.value, but is reactive, and can be controlled by a parent component.
  • an Output is similar to an Event dispatched with dispatchEvent, and is usually intercepted too by the parent component.

Let's create an interface for the inputs and outputs of our component:

interface ITodoListItemComponentData {
readonly message: Input<string>;
readonly remove: Output<void>;
}

The message input, of type string, contains the message to display on this "todo-list-item", and the remove output, emits when the user clicks on the "remove" button of this component.

Define the component's template data

Then, we'll define the interface of the data ($) required by our template:

interface ITemplateData {
readonly message$: IObservable<string>;
readonly $onClickRemoveButton: IObserver<any>;
}

Let's remember that our template requires a $.message$ variable, displayed in a <div>. In consequence, we'll have to provide an Observable<string> to fill this content.

Furthermore, we want to intercept the user's clicks on the remove button. So we'll use the Observer $.$onClickRemoveButton for this usage.

info

@lirx/dom aims to be strongly typed. It's more verbose, but it grants pre-compilation bug detection, and guaranties more robust applications. Component's template and inputs/outputs should have their own interfaces. It will help other developers and yourself understanding in one look what your component is capable of.

Create the component

To create a component, we'll use the class Component and some options:

export const TodoListItemComponent = new Component<HTMLElement, ITodoListItemComponentData, ITemplateData>({
name: 'app-todo-list-item',
template: compileReactiveHTMLAsComponentTemplate({ html }),
styles: [compileStyleAsComponentStyle(style)],
componentData: (): ITodoListItemComponentData => ({
message: input<string>(),
remove: output<void>(),
}),
templateData: (node: VirtualComponentNode<HTMLElement, ITodoListItemComponentData>): ITemplateData => {
const message$ = node.input$('message');
const $onClickRemoveButton = node.$output('remove');

return {
message$,
$onClickRemoveButton,
};
},
});

Alright, we'll explain the lines one by one:

new Component<HTMLElement, ITodoListItemComponentData, ITemplateData>(...)

We start by creating a new component of type HTMLElement with the inputs/outputs defined in ITodoListItemComponentData and the template's data ITemplateData.

name: 'app-todo-list-item',

Then we set a name for the component.

note

We recommend to always prefix the components with the same string. Usually it will be app-. It permits to easily do the distinction between the native elements, our app's components, and the components coming from external libraries. This is similar to a namespace, but for components.

template: compileReactiveHTMLAsComponentTemplate({ html }),

Before being assigned to the component, a template requires to be compiled using the function compileReactiveHTMLAsComponentTemplate. Compiling the templates to executable javascript, allows many internal optimizations, permits AOT compilation, and drastically increase the performances of the application.

styles: [compileStyleAsComponentStyle(style)],

Styles requires to be compiled too, using the function compileStyleAsComponentStyle.

note

The compilation of the html template as well as the css is not done by the class Component. It must be done by the developer using the corresponding functions. This is a design choice which allows the developers to share and reuse templates and styles across many components.

componentData: (): ITodoListItemComponentData => ({
message: input<string>(),
remove: output<void>(),
}),

If our component has inputs and/or outputs, we have to define them using the function componentData.

This function is called immediately when we create a component, and must return the inputs/outputs defined in the Data interface (here ITodoListItemComponentData).

templateData: (node: VirtualComponentNode<HTMLElement, ITodoListItemComponentData>): ITemplateData => {
// ...
},

Finally, we have the templateData function. This is where we'll define the interactions, the data, the pipelines, etc. of our component, and return the data used in the template.

This templateData function is called when the component is instanced, and receives a single argument: node: VirtualComponentNode<HTMLElement, ITodoListItemComponentData>. This is a representation of the node in the DOM. It has some useful methods and properties to: bind Observables with this node, listen to events, subscribe to inputs, dispatch outputs, etc. Then, this function returns the data required by the template.

So, we'll use this function to bind the inputs and outputs with the data required by the template:

// gets the 'message' input as an Observable
const message$ = node.input$('message');
// gets the 'remove' output as an Observer
const $onClickRemoveButton = node.$output('remove');

// returns the data required by the template
return {
message$,
$onClickRemoveButton,
};

Bring all together

Phew 😵‍💫, it was a technical part with a lot of new things to remember ! But we did it 🥳.

Here's the code for our todo-list-item component:

todo-list-item/todo-list-item.component.html
<div class="message">
{{ $.message$ }}
</div>
<button
class="remove-button"
(click)="$.$onClickRemoveButton"
>
Remove
</button>

The todo-list component

Alright, we've created our first component displaying a "message" provided as input and sending a "remove" event as output. Now, we'll create the component initializing and managing many todo-list-item, and call it todo-list.

When the user enters a new message, it will append a new todo-list-item to a list, and when the user clicks on the "remove" button, this item will be removed from this list.

Let's begin by creating the correct directory, and its associated files for this component:

cd ..
mkdir todo-list
cd todo-list
touch todo-list.component.html todo-list.component.scss todo-list.component.ts

I'll give you now the content of each file. However, do not focus yet on the code, as I'll explain it line by line right after. I'll skip too the explanation for the .scss file. It's only there to give a nice look to our component with just css.

todo-list/todo-list.component.html
<form
class="input-form"
(submit)="$.onFormSubmit"
>
<input
[value]="$.inputValue"
(input)="$.onInput"
placeholder="Enter task description"
/>
<button type="submit">
Add
</button>
</form>

<div class="list">
<app-todo-list-item
*for="let item of $.items"
$[message]="item.message"
$(remove)="() => $.removeItem(item)"
></app-todo-list-item>
</div>

Let's start with the template:

<form
class="input-form"
(submit)="$.onFormSubmit"
>
...
</form>

This creates a form containing an input with a submit button. It is used to let the user enter a message and submit it. As seen into the previous component, we subscribe to the "submit" event using the syntax: (submit)="$.onFormSubmit". So, the function $.onFormSubmit is called when the form is submitted.

This functions will look like this:

const onFormSubmit = (event: Event): void => {
event.preventDefault(); // we want to prevent our form to be submitted

// get the input's value
const value: string = inputValue().trim();

if (value !== '') {
// add a new entry
addItem(value);
}

// reset the input's value
inputValue.set('');
};

The next step - the input itself:

<input
[value]="$.inputValue"
(input)="$.onInput"
placeholder="Enter task description"
/>

[value]="$.inputValue": we ask the framework to subscribe to the Signal $.inputValue, and assign the received values to the <input>'s property value -> input.value = ... (documentation).

Thus, in the .ts file, we have to define this Signal:

const inputValue = signal<string>('');

Now, if update the signal though inputValue.set(...) (ex: inputValue.set('some content')), then, the input will immediately reflect this value.

(input)="$.onInput": you probably guess what it does 😉. It subscribes to the "input" events, and sends them to the function $.onInput.

In this function, we'll read the inputs' value, and update the Signal inputValue with this value:

const onInput = (event: Event): void => {
inputValue.set((event.target as HTMLInputElement).value);
};

Alright, let's continue

<app-todo-list-item
*for="let item of $.items"
$[message]="item.message"
$(remove)="() => $.removeItem(item)"
></app-todo-list-item>

*for="let item of $.items": this iterates over the array of "items" sent by the Signal $.items (documentation). For each value presents in this array, it creates an app-todo-list-item component. Each of these values are individually represented by the local variable item.

Now, we have to define $.items:

 const items = signal<ITodoListItemsList>([]);

Just like we did with the input's value, here, we're using a Signal to store our items. These items are simply objects with a message property:

interface ITodoListItem {
readonly message: string;
}

type ITodoListItemsList = readonly ITodoListItem[];

As you may notice, we're only using readonly arrays and readonly properties. Let me explain why:

Observables and Signals are streams of data. So mutating the data itself (like the property of an object or the content of an array), won't emit a new value into the corresponding Observable/Signal. In our case, it means "no DOM updates". Instead, we have to use immutable values, and send new references when a change occurs.

For a Signal, we'll have to use the .update(...) function:

const addItem = (
message: string,
): void => {
items.update((items: ITodoListItemsList): ITodoListItemsList => {
return [
...items,
{
message,
},
];
});
};

$[message]="item.message": this line sets item.message as the value of the 'message' input of the component app-todo-list-item (documentation). Instead of a static value, we could have used an Observable or Signal. It's up to you, as most attribute's binding accept a Reactive Value.

$(remove)="() => $.removeItem(item)": it subscribes to the output 'remove', and calls the function $.removeItem when it appends (documentation).

This function gets the list of items, searches for the index of the removed one, and updates the Signal items with a new list not containing this item:

const removeItem = (
item: ITodoListItem,
): void => {
const index: number = items().indexOf(item);
if (index !== -1) {
items.update((items: ITodoListItemsList): ITodoListItemsList => {
return [
...items.slice(0, index),
...items.slice(index + 1),
];
});
}
};

Finally, we have to declare which components we're using in this template:

template: compileReactiveHTMLAsComponentTemplate({
html,
customElements: [
TodoListItemComponent,
],
}),

Here, we're employing app-todo-list-item, so we have to import the real component into the .ts file. If we don't, the framework will emit an Error.

This is a security to ensure that every component exists and is properly loaded before rendered.

Hurrah 🤩, you've survived to the longest part !

The todo-list is ready and functional, however, we still have to define an entry point for our application.

Starting the application

A little more patience, we're almost done 😌.

We have all the required components, so the last step is to "start" our application. To do this, we'll use the function bootstrap:

main.ts
import { bootstrap } from '@lirx/dom';
import { TodoList } from './todo-list/todo-list.component';

bootstrap(TodoList);

It simply takes as input the "root" component of our application and injects it into the DOM, starting our application.

And voilà ! The application is ready to be used.

Conclusion

You are now a champion of @lirx/dom 🏆.

You've learned the basics, and know perfectly:

  • what is a component
  • what is a template, and how to create one
  • what are the components' inputs and outputs
  • how you can use the Observables and Signals to interact with your template

Now, it's time to create your own application 🚀.