This blog post is part of the series “Learning web development” – which teaches people who have never programmed how to create web apps with JavaScript.
To download the projects, go to the GitHub repository learning-web-dev-code
and follow the instructions there.
I’m interested in feedback! If there is something you don’t understand, please write a comment at the end of this page.
In this chapter, we’ll take a look at frontend frameworks – libraries that help with programming web user interfaces (“frontend” means “browser”, “backend” means “server”). We’ll use the frontend framework Preact to implement the frontend part of a todo list app – whose backend part we’ll implement in a future chapter.
In this section, we’ll learn a few new JavaScript features that we’ll need in our next project.
When it comes to changing an object (e.g. an array), we have two options:
As an example, consider the following array method (which can do even more but we ignore that):
array.splice(start, deleteCount)
It is a destructive operation that deletes deleteCount
elements, starting at index start
:
const arr = ['a', 'b', 'c', 'd'];
arr.splice(1, 2);
// This operation is destructive
assert.deepEqual(
arr, ['a', 'd']
);
In contrast, .toSpliced()
is the non-destructive version of .spliced()
: It returns a new array and doesn’t change the array it is invoked on:
const arr1 = ['a', 'b', 'c', 'd'];
const arr2 = arr1.toSpliced(1, 2);
// This operation is non-destructive
assert.deepEqual(
arr1, ['a', 'b', 'c', 'd']
);
assert.deepEqual(
arr2, ['a', 'd']
);
Other non-destructive array methods include .map()
and .filter()
– which create new arrays and don’t change the original.
Spreading (...
) is syntax that enables us to insert iterable values (such as arrays) into array literals:
> const arr = ['b', 'c'];
> ['a', ...arr, 'd']
[ 'a', 'b', 'c', 'd' ]
We can use spreading to copy an array:
const copy1 = [...original];
// Equivalent
const copy2 = original.slice();
const copy3 = Array.from(original);
We can also spread into object literals:
> const obj = {b: 2, c: 3};
> {a: 1, ...obj, d: 4}
{ a: 1, b: 2, c: 3, d: 4 }
We can use spreading to copy a plain object:
const copy = {...original};
In the previous two subsections, we talked about copying arrays and objects. One important thing to know about this kind of copying is that it is shallow: It only goes one level deep. Consider the following code:
const purchases = [
{product: 'apple', quantity: 2},
{product: 'toothbrush', quantity: 1},
];
const copy = [...purchases];
copy
is a fresh array and independent of the array purchases
: Removing an element from copy
does not affect purchases
.
However, both arrays still point to the same purchase objects. Therefore, if we change an object from copy
, that change does affect the same object in purchases
:
copy[0].quantity = 999;
assert.deepEqual(
purchases,
[
{product: 'apple', quantity: 999},
{product: 'toothbrush', quantity: 1},
]
);
What’s the solution then? If we want to avoid that, we have to make a deep copy:
import assert from 'node:assert/strict';
const deeplyCopyPurchases = (purchases) => {
// .map() creates a new array!
return purchases.map(
purchase => ({...purchase})
);
};
We map each original purchase
to its copy and therefore create a new array with copies. We can now change a quantity in a copy and it won’t affect the original array:
const purchases = [
{product: 'apple', quantity: 2},
{product: 'toothbrush', quantity: 1},
];
const copy = deeplyCopyPurchases(purchases);
copy[0].quantity = 999;
assert.deepEqual(
copy,
[
{product: 'apple', quantity: 999},
{product: 'toothbrush', quantity: 1},
]
);
assert.deepEqual(
purchases,
[
{product: 'apple', quantity: 2}, // no change!
{product: 'toothbrush', quantity: 1},
]
);
In the chapter on loops, we have already seen array destructuring:
const [elem0, elem1] = someIterable;
But we can also destructure objects:
const {key1: value1, key2: value2} = someObj;
This code is equivalent to:
const value1 = someObj.key1;
const value2 = someObj.key2;
Let’s look at a concrete example:
const obj = {key1: 'a', key2: 'b'};
const {key1: value1, key2: value2} = obj;
assert.equal(
value1, 'a'
);
assert.equal(
value2, 'b'
);
In the chapter on plain objects, we have seen an abbreviation called property value shorthand: If key and value have the same name, we can omit the key. In the next example, we use that abbreviation in line A:
const obj = {key1: 'a', key2: 'b'};
const {key1, key2} = obj; // (A)
// const {key1: key1, key2: key2} = obj;
assert.equal(
key1, 'a'
);
assert.equal(
key2, 'b'
);
Note that the variables now have the names key1
and key2
.
function
) We now learn a new way to create a function: The following code contains a function declaration.
function add(x, y) {
return x + y;
}
It is mostly equivalent to this code:
const add = (x, y) => x + y;
It normally doesn’t matter much which of these two syntaxes we use. These are the two biggest differences:
Upside of function declarations: We can call them before we declare them. That is sometimes useful because it gives us more freedom with arranging our code.
Upsides of arrow functions: We can omit the parentheses around single parameters and we can use expression bodies. Additionally, arrow functions can also be used as expressions. However, there is also an expression version of function declarations (that we don’t get into here).
Arrow functions have one additional benefit that is beyond the scope of this series: When doing object-oriented programming with objects or classes, they don’t interfere with accessing the special variable this
.
So far, we have used object destructuring in one location that receives value: a variable declaration. However, there are more locations where we can use it – e.g., a parameter definition (first line):
function hello({name}) {
return `Hello ${name}!`;
}
This code is equivalent to:
function hello(props) {
return `Hello ${props.name}!`;
}
We invoke hello()
like this:
assert.equal(
hello({name: 'Robin'}),
'Hello Robin!'
);
This way of passing arguments to functions has the benefit of each argument having a nice label that describes its role.
The term frontend basically means “browsers” (where the server is the backend).
A frontend framework is a library that helps with creating user interfaces in web browsers. The term framework hints at the fact that most frontend frameworks are like layers on top of the browser functionality that hide it to varying degrees – some more than others. Therefore:
So far, we have always used browser functionality directly. One key challenge is keeping model and view in sync. In the last chapter, we discussed two approaches for doing so:
The second approach is simpler and has worked well for us in project word-guessing-game
. A frontend framework takes that idea one step further: So far, the view has been HTML in the browser and we displayed the model in it. With a framework, the view is a function:
Each time the model changes, the view recreates the complete HTML. When it comes to the performance of that approach, it is important to keep one thing in mind:
Therefore, every time the model changes, the framework invokes the view function. It then compares the result with what’s already on the web page and only changes what is different.
Note that not all frameworks use this exact approach, but knowing it still gives you a rough idea of how using a framework differs from using browsers directly.
display-clicks-preact.html
– a first taste of Preact To explore frontend frameworks, we’ll use Preact. Very roughly, it is a simpler version of the popular frontend framework React. And it works well for the needs we have in this series.
In the project display-clicks-preact.html
we use a few tricks so that we can play with Preact without the need for npm-installing and building.
The body of display-clicks-preact.html
looks like this:
<h1>Display clicks (Preact)</h1>
<div id="app"></div>
In order to use Preact without building, we import the packages directly from the CDN esm.sh
. For that to work, we need to set up an import map:
<script type="importmap">
{
"imports": {
"preact": "https://esm.sh/preact",
"preact/": "https://esm.sh/preact/",
"htm/preact": "https://esm.sh/htm/preact?external=preact",
"@preact/signals": "https://esm.sh/@preact/signals?external=preact"
}
}
</script>
An import map uses JSON to define abbreviations for module specifiers – e.g., we can import from 'preact'
and JavaScript internally imports from https://esm.sh/preact
.
This should give you enough of an understanding of how import maps work. Explaining them further is beyond the scope of this series – you can read more here:
<script type="importmap">
”With this preparation, we can write our JavaScript code as if we had used npm to install packages.
We need these imports (what they do is explained soon):
import { render } from 'preact';
import { html } from 'htm/preact';
import { signal } from '@preact/signals';
The app starts like this:
render(
html`
<${ClickCounter} linkText="Click me!" />
`,
document.querySelector('#app')
);
We render the application “into” the <div>
with the ID app
. The first argument of render()
is the beginning of the view: We are already creating HTML. The html
plus the template literal is a new kind of literal, a tagged template: html
is a function that is called with the data inside the template (both the text and what we interpolate inside ${}
). The function html
returns a representation of the HTML that we wrote.
These are a few things to know about the syntax inside html
tagged templates:
We can define our own HTML tags – so-called components. A component is simply a function that returns HTML (an HTML representation). ClickCounter
is a component and we use it by interpolating it where we would normally put the name of an HTML element. We use HTML attribute syntax to pass arguments to ClickCounter
.
In HTML, there are two kinds HTML elements with tags:
<p>
).<br>
).In HTML, we can optionally write a void element with a slash at the end (e.g. <br/>
) – which indicates that it both opens and closes. In html
tagged templates, that’s the only way in which we can write them. In the previous code, ClickCounter
is a void element.
Spaces, tabs and newlines between lines are completely ignored (spaces and tabs within a single line are not ignored). In HTML, these are condensed and displayed as a single space.
ClickCounter
This is what our component looks like:
const appModel = signal(0); // (A)
function ClickCounter({ linkText }) {
function handleClick(event) {
event.preventDefault();
appModel.value++; // (B)
}
return html`
<div>
<a href="" onclick=${handleClick}>${linkText}</a>
</div>
<div>
Number of clicks: ${appModel.value}
</div>
`;
}
The component: The parameter of ClickCounter
is an object where each property corresponds to one HTML attribute that is mentioned in its “invocation” in the HTML. We are only interested in the property linkText
and access it via destructuring.
We use the attribute onclick
to attach a click listener to the <a>
element. The value of that attribute must be a function (that we interpolate).
The model: In line A, we create the model: We use a signal to do so. A signal wraps data and provides us with two operations:
appModel.value
returns the current model (initially the number 0
).appModel.value
changes the current model and causes all HTML to be recreated where appModel.value
was accessed.Therefore, if we change appModel.value
in line B, all of the HTML returned by ClickCounter()
is recreated because we have interpolated appModel.value
after “Number of clicks”.
If you want to dig deeper, you can check out “Signals” in the Preact documentation.
html
template tag vs. JSX We use Preact with html
tagged templates. However, there is also an extended version of JavaScript that has built-in syntax for creating (representations of) HTML. That built-in syntax is called JSX. It looks very similar to the tagged templates we are using. For this series, you don’t need to know what JSX is but it’s useful to at least be aware of that name. Extending JavaScript’s syntax has one significant downside: You can’t directly run the JavaScript code you write and always need a build step.
todo-list-browser/
Let’s move on to a bigger project: The browser side of a to-do list app. We’ll now use npm and build the web app – with the same setup as in the previous chapter. That means that you first have to npm-install all dependencies:
cd learning-web-dev-code/projects/todo-list-browser/
npm install
Let’s take a look at the files in that project.
js/app-model.js
Module app-model.js
contains functionality for managing our app’s model. That model is a core model wrapped inside a signal. The core model looks like this:
{
todos: [
{ text: 'Buy groceries', checked: true },
{ text: 'What dishes', checked: false },
],
}
We use the signal to keep track of changes. Assigning to property .value
of that signals triggers the re-rendering of HTML.
.value
of a signal There is one important thing to know about assigning to the .value
of a signal: We cannot reuse the old .value
; we must create a completely new deep copy – so that the signal doesn’t mix up old data and new data. That’s why we learned how to deep-copy data earlier in this chapter.
We provide a function for creating an empty app model so that all knowledge about the app model remains inside module app-model.js
. This kind of localization of knowledge makes code easier to maintain.
const createCoreModel = () => ({
todos: [],
});
export const createAppModel = () => signal(
createCoreModel()
);
Function addTodo()
adds a new todo to an app model:
const deepCopyTodos = (todos) => {
return todos.map(
todo => ({...todo})
);
};
export const addTodo = (appModel, text) => {
const oldCoreModel = appModel.value;
const newCoreModel = {
todos: [
...deepCopyTodos(oldCoreModel.todos),
{ text, checked: false },
],
};
appModel.value = newCoreModel;
};
We use spreading to append a new todo to a deep copy of the old todos.
The following function deletes the todo at index
:
export const deleteTodo = (appModel, index) => {
const oldCoreModel = appModel.value;
const newCoreModel = createCoreModel();
for (const [i, todo] of oldCoreModel.todos.entries()) {
if (i !== index) {
newCoreModel.todos.push(
{ ...todo } // copy
);
}
}
appModel.value = newCoreModel;
};
We deep-copy all todos except for the one at index
.
.checked
of a todo To update property .checked
we (non-destructively) replace the todo at index
and deep-copy all other todos.
export const updateChecked = (appModel, index, checked) => {
const oldCoreModel = appModel.value;
const newCoreModel = {
todos: oldCoreModel.todos.map(
(todo, i) => {
if (i === index) {
// Change todo non-destructively
return {
text: todo.text,
checked,
};
} else {
return { ...todo }; // copy
}
}
),
};
appModel.value = newCoreModel;
};
app-model_test.js
Like in our last project, there are tests for the app model that we can run via npm test
. This is one example:
function createTestModel() {
return signal({
todos: [
{ text: 'Groceries', checked: true },
{ text: 'Dishes', checked: false },
],
});
}
test('addTodo()', () => {
const model = createTestModel();
addTodo(model, 'Clothes');
assert.deepEqual(
model.value,
{
todos: [
{ text: 'Groceries', checked: true },
{ text: 'Dishes', checked: false },
{ text: 'Clothes', checked: false },
],
}
);
});
main.js
Since app-model.js
does a lot of work for us with regard to managing the app model, the view part of our app is relatively simple – partly due to the help we get from Preact.
At the beginning of the module, we create an app model that is used by all components:
const appModel = createAppModel();
The app is set up like this:
render(
html`<${App} />`,
document.body
);
This time, we render into the <body>
of the HTML page: We create all of the body’s HTML via JavaScript. The component that does that is called App
:
function App() {
const add = () => {
const todoInput = document.querySelector('#todoInput'); // (A)
addTodo(appModel, todoInput.value);
todoInput.value = '';
};
return html`
<h1>Todo list</h1>
<${Todos} todos=${appModel.value.todos}
editedIndex=${appModel.value.editedIndex}
/>
<div>
<button onclick=${add}>Add:</button>
${' '}
<input id="todoInput" type="text" />
</div>
`;
}
The ${' '}
is a trick to insert a space between the two lines – since Preact ignores spaces and newlines between lines.
<input type="text">
elements are one area where Preact has a few quirks: Normally, we rerender whenever the model changes. However, with a text field, doing that for every character the user enters can make user interfaces slow. Therefore, we don’t use Preact here and access the DOM directly (line A). For more on this topic, you can read “Controlled & Uncontrolled Components” in the Preact documentation.
The next level of our user interfaces is the list of todos, as implemented by the component Todo
:
function Todos({ todos }) {
return todos.map(
(todo, index) => {
return html`
<${Todo} index=${index} todo=${todo} />
`;
}
);
}
Note that we don’t return a single HTML fragment but an array of HTML fragments. It’s convenient that Preact lets us do that.
Lastly, we get to the lowest level of our user interface: The todo item.
function Todo({ index, todo }) {
function onchange(event) {
updateChecked(appModel, index, event.target.checked);
}
function onclick() {
deleteTodo(appModel, index);
}
return html`
<div>
<label>
<input type="checkbox" checked=${todo.checked} onchange=${onchange} />
${' '}
${todo.text}
</label>
${' '}
<span class="delete-icon" onclick=${onclick}>×</span>
</div>
`;
}
We react to events and trigger user interface changes via appModel
.
display-clicks-preact.html
: Change the code so that the app renders into document.body
and all of the HTML is created via JavaScript.todo-list-browser/
: Currently, we need to click the “Add” button to add a todo to the list. Change the code, so that a todo is also added if we press return inside the text field after the button. You can do that by listening to change events.