List rendering
Hof.js supports deep observability for arrays. This means that the addition, update or deletion of an array element is observed and the corresponding parts of the user interface are automatically updated - all without the overhead of a virtual Dom.
To familiarize ourselves with this concept, let’s implement a small app that shows a list of persons and supports all CRUD operations. The finished app should look and work like the one shown below.
Rendered webpage
First, let’s implement a simple class to hold a person’s data.
person.js
class Person {
constructor(name = "", age = "") {
this.id = (name && age) ? Person.counter++ : "";
this.name = name;
this.age = age;
}
static counter = 1;
}
Next, we implement a component person-data to render our app. It contains a component of type person-list to render a list of persons and ui elements to perform all CRUD operations on the list elements. The editing area consists of two components of type person-input and allows the user to enter data for a new person to be added or to update the data of an already selected person.
person-data.js
customElements.define("person-data", class extends HofHtmlElement {
selected = new Person();
persons = [new Person("Alex", 21), new Person("Chris", 19), new Person("Mike", 19)];
create() { this.selected = new Person(); }
edit(person) { this.selected = { ...person }; } // Copy object to avoid live update on text change
remove(person) { this.persons.splice(this.findIndex(person), 1); this.create(); }
save() {
if (this.selected.id) // Existing person?
this.persons.splice(this.findIndex(this.selected), 1, this.selected);
else
this.persons.push(new Person(this.selected.name, this.selected.age));
this.create();
}
findIndex(person) { return this.persons.findIndex(p => p.id == person.id); }
templates = [
() => html`
<fieldset>
<person-data-input label="Name" value="${this.selected.name}" change="${(e) => this.selected.name = e.target.value}"></person-data-input>
<person-data-input label="Age" value="${this.selected.age}" change="${(e) => this.selected.age = e.target.value}"></person-data-input>
<button onclick="${this.save}">Save</button>
</fieldset>
${this.persons.length} persons in list
<person-data-list persons="${this.persons}" edititem="${this.edit}" deleteitem="${this.remove}"></person-data-list>
<a href="#" onclick="${this.create}">Create</a>
`
]
})
The above class contains the persons and selected properties, which represent the list of all persons to be displayed and the currently selected person. Additionally, the class contains several methods that implement CRUD operations:
- The hyperlink at the end of the template calls the method
create. Here theselectedproperty is set to a newpersoninstance. Sincethis.selected.nameorthis.selected.ageis passed to the respectiveperson-data-inputcomponent in the template, a new empty person is rendered in the editing area. - The
editandremovemethods, on the other hand, are passed down to theperson-data-listcomponent. If the edit or delete link of a person rendered by theperson-data-listcomponent is pressed, the mentioned methods are called with the affected person as parameter:- In
edittheselectedproperty is set to the person whose edit link was clicked in the list, so that their data is displayed in the edit area. - In
removevia arraysplicethe selected person is deleted frompersons.
- In
- Method
saveadds a new person with data fromselectedto arraypersonsor updates an array entry if an existing person was selected earlier. Finallycreate()empties the editing area and enables the entry of a new person. - The
findIndexmethod is a helper method to find a person in the array by its ID and return its index.
Now lets take a look at the component person-list.
person-list.js
customElements.define("person-data-list", class extends HofHtmlElement {
// Property
persons = [new Person("Alex", 21), new Person("Chris", 19), new Person("Mike", 19)];
// Helper Function
getBirthday(person) {
let birthday = new Date();
birthday.setFullYear(birthday.getFullYear() - person.age);
return birthday.toLocaleDateString();
}
edititem = null; // Callback from parent
deleteitem = null; // Callback from parent
templates = list(this.persons, (person, initialInsertIndex, updated) => html`
<li>
[${initialInsertIndex}] ${person.name} - ${person.age} years (birthday: ${this.getBirthday(person)})
[<a href="#" onclick="${() => this.edititem(person)}">Edit</a>]
[<a href="#" onclick="${() => this.deleteitem(person)}">Delete</a>]
${updated ? "(update)" : ""}
</li>`, "ul"
)
})
This component uses an function within the template named list with the following syntax: list(listProperty, (listItem, insertIndex, updated) => htmlExpr, parentEl, renderParentOnEmptyList):
- The first parameter “listProperty” can be any regular or derived property to be rendered.
- The second parameter is an arrow function that is called for each element of the array:
- Lambda parameter
listItemreferences the current array element to be rendered. - Lambda Parameter
insertIndexreferences the index of the current array element at insertion time. - Lambda Parameter
updatedtells if current element is rendered the first time or updated.
- Lambda parameter
- The
htmlExprto the right of the arrow of the lambda expression must return a html string and is used as html template for each element in the array. It transforms an array element into html. - Optional parameter
parentElallows to specify a parent element for the list, e.g.ul(default isdiv). - Optional parameter
renderParentOnEmptyListcauses the parent element of the list not to be rendered if the list should be empty. This is important for valid HTML, e.g. to avoidulwithoutlielements.
Note
- All lambda parameters of list can be named as desired.
- Parameters
insertIndexandupdatedare optional. If you are interested inupdated, but non oninsertIndexuse _ as parameter name.
Next, lets take a look at the implementation of our person-input component.
person-input.js
customElements.define("person-data-input", class extends HofHtmlElement {
value = "";
label = "";
change = null;
constructor() {
super("label")
}
templates = html`${this.label}: <input value="${this.value}" onchange="${this.change}" />`
})
This component is pretty simple. It renders an input element enclosed in a label. It displays the value provided from the parent and calls the change callback of the parent if the rendered value was changed by the user. This component is used two times by the parent person-data component:
<person-data-input label="Name" value="${this.selected.name}" change="${(event) => this.selected.name = event.target.value}"></person-data-input>
<person-data-input label="Age" value="${this.selected.age}" change="${(event) => this.selected.age = event.target.value}"></person-data-input>
As a consequence, this component display the name or age of the person in selected and updates the property selected with input provided by the user. This means, property selected is synched with data displayed and updated by the user.
Finally, lets take a look at the webpage.
person-list-app.html
<!DOCTYPE html>
<html>
<head>
<title>Hello world app</title>
<script src="../../lib/nomodule/hof.js"></script>
<script src="person.js"></script>
<script src="person-input.js"></script>
<script src="person-list.js"></script>
<script src="person-data.js"></script>
</head>
<body>
<person-data></person-data>
</body>
</html>
And that’s it. You successfully created your first app that renders a list!
Note
You might think: Why do i have to use
listif i can just useArray.mapto generate output based on an array? The answer is runtime complexity:
- Method
Array.mapre-renders everything - even if only one item was added, updated or deleted.- Html method
listprovides O(1) in case of regular list properties and O(N) for derived properties in the worst case. This is far superior compared with approaches based on virtual dom comparisons.