An SPA by hand
Assignment
Martina Hostettler wants to see what makes an SPA work before the volunteers choose a tool.
As a member, I want to browse the catalog in the browser and open a page of its own for every thing, so that I can send a friend a link to exactly this thing.
The acceptance criteria are:
- The catalog shows all things with name, category and availability.
- A click on a thing opens its page with the deposit and the date from which it is back. The address changes, and a link to this address opens this page directly.
- The browser's "Back" button leads back to the catalog.
- While loading and when the server reports an error, a clear message appears.
Learning goals
After this chapter you can
- build an interface from a state and a render function,
- handle events so that they change the state and draw again,
- insert text into HTML safely,
- build a router on the hash of the address that supports links and the "Back" button,
- explain where updating the page by hand reaches its limits.
For this you need the DOM and events from the JS workshop and loading with fetch from the chapter Asynchronous JavaScript.
One state for the whole app
Section titled “One state for the whole app”An SPA rests on an idea that Vue uses as well. Everything the page shows lives in one object, the state. A function render reads the state and builds the page from it. When something happens, a click, some input, an answer from the server, the code only changes the state and calls render again.
<!doctype html><div id="app"></div><script> // Everything the page shows comes from this one object const state = { reservations: 0 }
function render() { document.querySelector('#app').innerHTML = ` <p>Reservations today ${state.reservations}</p> <button type="button" data-action="reserve">Reserve</button>` }
// One listener on the container, because every render replaces the button document.querySelector('#app').addEventListener('click', (event) => { if (event.target.dataset.action === 'reserve') { state.reservations += 1 render() } })
render() document.querySelector('[data-action="reserve"]').click() document.querySelector('[data-action="reserve"]').click() console.log(document.querySelector('#app p').textContent)</script>The click on the button doesn't change the paragraph, it changes state.reservations. Then render draws the page anew. Since render replaces the button with a new one every time, the listener doesn't sit on the button but on the container. A click rises up through the DOM, and data-action says what to do. This pattern is called event delegation, and the unit The DOM and events shows it in more detail.
The cycle has an advantage. What the page shows depends only on the state. Whoever wants to know why something appears on the page looks at the state, not at a long series of changes to the DOM.
The render function
Section titled “The render function”The render function builds HTML as text with template literals and inserts it with innerHTML. For a list, map turns every thing into an entry, and join('') puts the entries together.
<!doctype html><ul id="catalog"></ul><script> const items = [ { id: 1, name: 'Drill', availableFrom: null }, { id: 2, name: 'Tent for four', availableFrom: '2026-11-12' }, { id: 99, name: '<img src=x onerror=alert(1)>', availableFrom: null }, ]
// Turns the characters that mean something in HTML into plain text function escapeHtml(text) { return text.replaceAll('&', '&').replaceAll('<', '<').replaceAll('>', '>').replaceAll('"', '"') }
function renderItem(item) { const state = item.availableFrom === null ? 'Available' : 'On loan' return `<li><a href="#/items/${item.id}">${escapeHtml(item.name)}</a> ${state}</li>` }
document.querySelector('#catalog').innerHTML = items.map(renderItem).join('') console.log(document.querySelectorAll('#catalog li').length, document.querySelectorAll('#catalog img').length) console.log(document.querySelector('#catalog li:last-child').textContent)</script>The third thing has a name that looks like HTML. Without care, innerHTML would turn it into an element and run its code. That's why every text that comes from data goes through escapeHtml. The function replaces the characters that mean something in HTML with their text form. The output shows that no image was created and that the name appears as text. Fixed texts such as "Available" are written by the code itself and don't need this.
A router on the hash
Section titled “A router on the hash”An SPA shows different views under different addresses. The simplest way to do this is the hash, the part of the address after the # sign. When only the hash changes, the browser doesn't load a new page. It adds the new address to the list for the "Back" button and fires the event hashchange.
<!doctype html><div id="app"></div><script> const items = [ { id: 1, name: 'Drill', deposit: 20 }, { id: 6, name: 'Projector', deposit: 80 }, ]
// Reads the part of the address after the #, for example #/items/6 function parseRoute(hash) { const match = /^#\/items\/(\d+)$/.exec(hash) if (match) return { name: 'item', id: Number(match[1]) } if (hash === '' || hash === '#/') return { name: 'catalog' } return { name: 'notFound' } }
function render() { const route = parseRoute(location.hash) const app = document.querySelector('#app') if (route.name === 'catalog') { app.innerHTML = items.map((item) => `<a href="#/items/${item.id}">${item.name}</a>`).join(' ') } else if (route.name === 'item') { const item = items.find((entry) => entry.id === route.id) app.innerHTML = item ? `<h1>${item.name}</h1><p>Deposit CHF ${item.deposit}</p><a href="#/">To the catalog</a>` : '<p>This thing does not exist.</p>' } else { app.innerHTML = '<p>This page does not exist.</p>' } console.log(route.name, app.querySelector('h1, a, p').textContent) }
addEventListener('hashchange', render) render() document.querySelector('a[href="#/items/6"]').click()</script>parseRoute translates the hash into a route, an object with a name and, where needed, the id of the thing. A regular expression recognizes addresses such as #/items/6, and Number turns the text into a number so that the comparison with === works. render picks the view according to the route. The links are ordinary links to a hash, <a href="#/items/6">. A click changes the address, hashchange calls render, and the "Back" button works by itself.
The browser never sends the hash to the server. For the server there is only one page, no matter which thing is being shown. That's why a link such as http://localhost:3000/#/items/6 also works as the first address someone opens. Nicer addresses without # are possible with the History API, which part 6 uses with Vue Router.
The SPA with data
Section titled “The SPA with data”In the exercise project, the parts come together. The function start sets up the state, draws a loading message, loads the catalog with loadItems from part 1 and then draws the catalog or the message of an error.
import { loadItems } from './load.js'import { renderDetail, renderList, renderMessage } from './render.js'import { parseRoute } from './router.js'
// Starts the app in the element root and resolves once the catalog is loadedexport async function start(root) { // Everything the app shows comes from this one object const state = { status: 'loading', items: [], error: null, route: parseRoute(location.hash) }
function render() { if (state.status === 'loading') { root.innerHTML = renderMessage('Loading the catalog …') } else if (state.status === 'error') { root.innerHTML = renderMessage(state.error) } else if (state.route.name === 'catalog') { root.innerHTML = renderList(state.items) } else if (state.route.name === 'item') { const item = state.items.find((entry) => entry.id === state.route.id) root.innerHTML = item ? renderDetail(item) : renderMessage('This thing does not exist.') } else { root.innerHTML = renderMessage('This page does not exist.') } }
addEventListener('hashchange', () => { state.route = parseRoute(location.hash) render() })
render() try { state.items = await loadItems('/items') state.status = 'success' } catch (error) { state.status = 'error' state.error = error.message } render()}Besides the things, the state holds status and error from the chapter Asynchronous JavaScript and the current route. render decides what appears based on this object alone. The functions that build the HTML live in the module render.js and know neither the state nor the DOM. They receive data and return text, which makes them easy to test.
The SPA in the browser
Section titled “The SPA in the browser”The SPA consists of ES modules, and the browser loads modules only over HTTP. That's why the small server from part 1 now delivers the files of the app as well. At http://localhost:3000/ the page comes from the folder app, and at /items the catalog, as before. Page and data share one address, which saves trouble that part 7 explains.
npm run serverThen open http://localhost:3000/ in the browser and the "Network" area in the DevTools. On loading, the page, the modules and the request to /items appear. A click on a thing doesn't trigger a single request, as in the figure from the chapter Web pages and web apps.
The limits of handwork
Section titled “The limits of handwork”The cycle of state and render is simple as long as render may draw everything anew. With input fields that goes wrong.
<!doctype html><div id="app"></div><script> const state = { query: '', names: ['Drill', 'Jigsaw', 'Tent'] }
// Renders everything again on every keystroke, the search field included function render() { const found = state.names.filter((name) => name.toLowerCase().includes(state.query.toLowerCase())) document.querySelector('#app').innerHTML = ` <input id="search" value="${state.query}" aria-label="Search"> <ul>${found.map((name) => `<li>${name}</li>`).join('')}</ul>` }
document.querySelector('#app').addEventListener('input', (event) => { state.query = event.target.value render() })
render() // Someone types into the search field const search = document.querySelector('#search') search.focus() search.value = 'saw' search.dispatchEvent(new Event('input', { bubbles: true }))
console.log(document.querySelectorAll('li').length, search.isConnected, document.activeElement === document.querySelector('#search'))</script>After the first letter, render draws everything anew, the search field included. The field someone types into disappears and is replaced by a new one that doesn't have the focus. Whoever keeps typing types into nothing. That's not the only catch.
- Every field that should keep its focus, its selection or its scroll position must not be replaced by
render. The render function has to know which parts it may draw anew. - Every text from data has to go through
escapeHtml, also in the value of attributes such asvalue. A forgotten call is a hole. - With many entries, rebuilding everything on every change takes time.
- With every feature, the code grows that keeps track of which parts of the page depend on which parts of the state.
A framework such as Vue takes exactly this work off your hands. In the template you describe what the page looks like based on the state, Vue keeps track of which parts depend on which data and changes only what has really changed. A search field keeps its focus, and Vue always inserts text safely. That's what part 3 is about.
In code
Section titled “In code”Work with the example on the router. Read the code and write down what you expect before you change anything.
<!doctype html><div id="app"></div><script> const items = [ { id: 1, name: 'Drill', deposit: 20 }, { id: 6, name: 'Projector', deposit: 80 }, ]
// Reads the part of the address after the #, for example #/items/6 function parseRoute(hash) { const match = /^#\/items\/(\d+)$/.exec(hash) if (match) return { name: 'item', id: Number(match[1]) } if (hash === '' || hash === '#/') return { name: 'catalog' } return { name: 'notFound' } }
function render() { const route = parseRoute(location.hash) const app = document.querySelector('#app') if (route.name === 'catalog') { app.innerHTML = items.map((item) => `<a href="#/items/${item.id}">${item.name}</a>`).join(' ') } else if (route.name === 'item') { const item = items.find((entry) => entry.id === route.id) app.innerHTML = item ? `<h1>${item.name}</h1><p>Deposit CHF ${item.deposit}</p><a href="#/">To the catalog</a>` : '<p>This thing does not exist.</p>' } else { app.innerHTML = '<p>This page does not exist.</p>' } console.log(route.name, app.querySelector('h1, a, p').textContent) }
addEventListener('hashchange', render) render() document.querySelector('a[href="#/items/6"]').click()</script>- What appears if you write
location.hash = '#/items/99'at the end instead of the click? And with'#/items/projector'? - Add a link to
#/aboutto the catalog and catch the route inparseRoute, so that a short page about the association appears. - What happens if you leave out the
Number(...)inparseRoute? Why? - Click a link in the frame below the code and then "To the catalog". Which lines appear in the output?
Exercises
Section titled “Exercises”The exercises build the SPA of the assignment. You find the project in the course repo in the folder exercises/02-spa/spa-by-hand/en. Copy the folder, install the packages and run the tests.
npm installnpm testWith npm run server and the address http://localhost:3000/ you see the app in the browser. The tests check the functions and the app without a server, with a stand-in for fetch.
Exercise Getting started
Write the function renderList(items) in app/render.js. It returns a list <ul class="catalog"> with one entry per thing. Every entry contains a link with the name of the thing that points to its page, such as #/items/6, plus the category and "Available" or "On loan". Names and categories go through escapeHtml from app/html.js.
Solution
import { formatDate, formatDeposit } from './format.js'import { escapeHtml } from './html.js'
function availability(item) { return item.availableFrom === null ? 'Available' : 'On loan'}
// The catalog as a list, every entry links to the page of its thingexport function renderList(items) { const entries = items.map( (item) => ` <li> <a href="#/items/${item.id}">${escapeHtml(item.name)}</a> <span class="category">${escapeHtml(item.category)}</span> <span class="state">${availability(item)}</span> </li>`, ) return `<ul class="catalog">${entries.join('')}</ul>`}map turns every thing into an entry, and join('') puts them together without commas. The availability sits in a small function of its own, because the page of a thing needs it too in the next step. Without escapeHtml the last test fails, and the name with <b> would appear in bold instead of as text.
Exercise Going further
Write the function parseRoute(hash) in app/router.js. It returns { name: 'catalog' } for an empty address and for #/, { name: 'item', id: 6 } for #/items/6 and { name: 'notFound' } for everything else. Then write renderDetail(item) for the page of a thing, with the name as heading, the category, the deposit, the availability or the date from which it is back, and a link back to the catalog.
Solution
// Reads the part of the address after the #, for example #/items/6, and returns the routeexport function parseRoute(hash) { if (hash === '' || hash === '#/') return { name: 'catalog' } const match = /^#\/items\/(\d+)$/.exec(hash) if (match) return { name: 'item', id: Number(match[1]) } return { name: 'notFound' }}// The page of one thingexport function renderDetail(item) { const back = item.availableFrom === null ? availability(item) : `Available again from ${formatDate(item.availableFrom)}` return ` <article> <h1>${escapeHtml(item.name)}</h1> <p>${escapeHtml(item.category)}, deposit ${formatDeposit(item.deposit)}</p> <p>${back}</p> <a href="#/">Back to the catalog</a> </article>`}After #/items/ the regular expression accepts digits only, so #/items/six ends up as notFound. For deposit and date, renderDetail uses the functions from app/format.js, the date in UTC as in the chapter on props. You don't have to change the app itself, since render in app.js already calls the functions. The last test opens the app directly with the address of the projector.
Exercise Challenge
Add a search field with the id search above the catalog. While someone types, the list shows only things whose name contains the search text, regardless of upper and lower case. The search field should keep its focus and its text.
Solution
// Only the list changes while someone types, so the search field keeps its focus function renderResults() { const query = state.query.trim().toLowerCase() const found = state.items.filter((item) => item.name.toLowerCase().includes(query)) root.querySelector('#results').innerHTML = renderList(found) }
function render() { if (state.status === 'loading') { root.innerHTML = renderMessage('Loading the catalog …') } else if (state.status === 'error') { root.innerHTML = renderMessage(state.error) } else if (state.route.name === 'catalog') { root.innerHTML = ` <input id="search" type="search" aria-label="Search the catalog" placeholder="Search"> <div id="results"></div>` root.querySelector('#search').value = state.query renderResults() } else if (state.route.name === 'item') { const item = state.items.find((entry) => entry.id === state.route.id) root.innerHTML = item ? renderDetail(item) : renderMessage('This thing does not exist.') } else { root.innerHTML = renderMessage('This page does not exist.') } }
root.addEventListener('input', (event) => { if (event.target.id !== 'search') return state.query = event.target.value renderResults() })The trick is not to draw everything anew. For the catalog, render draws the search field and an empty container #results. While someone types, the listener calls only renderResults, which fills the container alone. The search field stays the same element, with focus and text. The search text still lives in the state, so that it is back in the field after a visit to the page of a thing. Exactly this decision, which parts may be created anew, is what Vue makes for you in part 3.
Common mistakes
Section titled “Common mistakes”| Mistake | What helps |
|---|---|
A listener sits on a button that render later replaces and stops reacting. | Attach the listener to a container that render doesn't replace and work with event delegation. |
A handler changes the page directly, and the change is gone after the next render. | Change only the state in the handler and then call render. |
Text from data ends up in innerHTML without escapeHtml. | Send every text from data through escapeHtml, in attributes too. |
route.id === item.id is never true, because the id from the address is a string. | Convert the id with Number(...). |
A link without # reloads the whole page. | Write links to a hash for the views of the SPA, href="#/items/6". |
| The page stays empty because it was opened directly as a file. | Open the app through the server, http://localhost:3000/. |
An AI assistant writes onclick="reserve()" into the HTML of the render function. | In a module the function isn't global and isn't found. Use event delegation. |
Self-check
Section titled “Self-check”Self-check
- What does an event handler do in an SPA that follows the pattern of this chapter, and what doesn't it do?
- Why does the listener for the buttons sit on the container and not on the buttons?
- What happens on a click on
<a href="#/items/6">, and why doesn't the browser load a new page? - Why does the search field in the example lose its focus, and what helps?
Answers
- It changes the state and calls
render. It doesn't change the page directly. renderreplaces the buttons every time it draws. A listener on an old button would sit on an element that is no longer on the page. The container stays, and the click rises up to it.- The hash of the address changes, the browser adds the address to the history and fires
hashchange. A change to the hash alone loads no new page, and the browser doesn't send the hash to the server. renderreplaces all the HTML, the search field included, and the new field doesn't have the focus. What helps is drawing only the list anew and leaving the field in place.
Assignment
The first version runs, and Martina Hostettler showed it to a volunteer who works as a software developer. He likes it, but he has two questions. "How do you want to publish this, with all these separate files? And do you reload the browser by hand on every change?" The next chapter is about the tools that take care of this.
Text and figures are licensed under CC BY-SA 4.0, the code under the MIT License and the course data under CC0 1.0. The figures use the font Kalam under the SIL Open Font License 1.1. The sources are on GitHub.