Learning web development: Frontend frameworks

[2025-09-09] dev, javascript, learning web dev
(Ad, please don’t block)

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.

More JavaScript features  

In this section, we’ll learn a few new JavaScript features that we’ll need in our next project.

Destructive vs. non-destructive operations  

When it comes to changing an object (e.g. an array), we have two options:

  • We can change the object. That is called a destructive operation.
  • We can create a new, changed version of the object (the original is unchanged, the copy contains the changes). That is called a non-destructive operation.

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 into arrays  

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);

Spreading into objects  

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};

Deep vs. shallow copying  

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},
  ]
);

Object destructuring  

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'
);

Object destructuring with property value shorthands  

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 declarations (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.

Using object destructuring for parameters  

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.

Frontend frameworks  

What is a frontend framework?  

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:

  • Upside of using a frontend framework: It makes creating web user interfaces easier; especially complicated ones.
  • Downside: It adds complexity: There is more to learn, we have to manage more dependencies (upgrades etc.), and more code always increases the likeliness of bugs.

How do frontend frameworks work?  

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:

  1. Incrementally updating the model, then incrementally updating the view
  2. Incrementally updating the model, then displaying all of the model in the view

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:

  • Input: the model
  • Output: the view (HTML; or rather, a compact representation of HTML)

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:

  • Creating the output is fast.
  • Updating the page in the browser is slow.

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.

Project: 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.

HTML  

The body of display-clicks-preact.html looks like this:

<h1>Display clicks (Preact)</h1>
<div id="app"></div>

JavaScript  

An import map  

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:

The start of the app  

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:

    • Some elements have an opening and a closing tag and can have children (e.g. <p>).
    • Void elements only have one tag and can’t have any children (e.g. <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.

The component 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:

  • Getting appModel.value returns the current model (initially the number 0).
  • Setting 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.

The 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.

Project: 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.

Assigning to the .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.

Creating an empty app model  

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()
);

Adding a todo  

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.

Deleting a todo  

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.

Updating .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;
};

Tests for the app model: 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.

Exercises (without solutions)  

  • 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.