Chapter 5
Rendering
How to model interface data as arrays of objects and turn it into DOM safely, with templates, empty states, and stable ids, building a searchable resource library.
Rendering is the code that turns state into elements on the page. Picture a team’s library of design and front-end resources: articles on modal dialogs, fluid type, error messages, empty states. Someone opens it looking for something on accessibility that a beginner can follow. They type “focus” into a search box, pick “Accessibility” from a category menu, and sort by difficulty. The list narrows as they type. They mark one article as a favorite. When their search is too narrow, the page tells them nothing matched and offers a way to clear it, instead of showing a blank space.
Every one of those behaviors is the same move. There is some data, there are a few choices the person has made, and the list on screen is whatever those choices pick out of the data. This chapter covers both halves: how to describe interface data with arrays and objects, and how to turn that data into elements without losing focus or opening a security hole.
State in, interface out
The resource library answers the introduction’s first three questions like this.
- What state does it remember? The list of resources, including which ones are favorites, plus the person’s current search text, category, and sort order.
- What events change it? Typing in the search box, choosing a category or sort option, pressing a favorite button, and pressing “Clear search and filters.”
- What updates on screen? The list of results, the result count, the empty state, and the pressed state of a favorite button.
The visible list isn’t stored anywhere. It’s derived: every time something changes, a function takes the full list and the current choices and calculates which resources to show, in what order. A second function turns that answer into elements. Keeping those two steps apart is what makes the code easy to reason about. If the wrong items appear, the calculation is wrong. If the right items appear but look wrong, the rendering is wrong.
Modeling interface data with arrays and objects
An object describes one thing. Its properties are the facts the interface needs about that thing.
const resource = {
id: 'resource-1',
title: 'Accessible Modal Dialog Pattern',
category: 'Accessibility',
difficulty: 'Intermediate',
favorite: false,
};
An array holds many things of the same shape, in order. A list of resources is an array of objects like that one. So is a list of tasks, notifications, search results, table rows, or the options in a select menu. Once you notice this, most interface data looks familiar: cards, rows, and options are objects, and the lists they sit in are arrays.
A few modeling habits make rendering easier later:
- Give every item an
idthat never changes. You’ll use it to connect an element on the page back to its object. - Store facts and derive the rest. Store
difficulty: 'Intermediate', not a CSS class name or a sentence like “Intermediate level.” - Keep each property one type.
favoriteis alwaystrueorfalse, never'yes'or missing.
Objects also work well as lookup tables. To sort by difficulty, you need to know that “Beginner” comes before “Intermediate,” which alphabetical order doesn’t give you:
const difficultyRank = { Beginner: 1, Intermediate: 2, Advanced: 3 };
difficultyRank['Advanced']; // 3
Array methods for interface data
Four array methods do most of the work in rendering code. Each takes a callback, and each is easier to read than the equivalent for loop because its name says what it’s for.
filter returns a new array of the items where the callback returns true. It’s how a search or category narrows a list.
const accessibility = resources.filter(
(resource) => resource.category === 'Accessibility'
);
map returns a new array of the same length, with each item transformed by the callback. It’s how a list of objects becomes a list of elements or a list of titles.
const titles = resources.map((resource) => resource.title);
find returns the first item where the callback returns true, or undefined if none match. It’s how you get the object for a button someone just pressed.
const resource = resources.find((item) => item.id === 'resource-4');
sort reorders an array using a compare function that returns a negative number when a should come first, a positive number when b should, and zero when they’re equal. localeCompare handles text, and subtraction handles numbers.
const byTitle = [...resources].sort((a, b) => a.title.localeCompare(b.title));
const byDifficulty = [...resources].sort(
(a, b) => difficultyRank[a.difficulty] - difficultyRank[b.difficulty]
);
There is one important difference between these methods. filter, map, and find leave the original array alone, but sort reorders the array you call it on and returns that same array. The [...resources] syntax makes a shallow copy first so the original order survives. Current browsers also have toSorted(), which returns a sorted copy and saves you the spread. The debugging challenge in this chapter shows what happens when you forget the copy.
Creating elements
To turn one object into an element, create the element, set its text and attributes, and append it.
function createResourceItem(resource) {
const item = document.createElement('li');
item.dataset.resourceId = resource.id;
const title = document.createElement('h3');
title.textContent = resource.title;
item.append(title);
return item;
}
append accepts any number of elements and strings, and inserts strings as plain text. replaceChildren removes everything inside an element and inserts what you give it, so a whole list can be redrawn with one call:
list.replaceChildren(...visibleResources.map(createResourceItem));
The ... spreads the array of elements into separate arguments. Calling replaceChildren() with no arguments empties the element.
Templates and fragments
Building a card with createElement gets long once it has a heading, metadata, and a button. A <template> element lets you write that structure once in HTML. The browser parses it but doesn’t render it, run its scripts, or include it in the accessibility tree.
<template data-resource-template>
<li data-resource>
<h3 data-title></h3>
<p><span data-category></span>, <span data-difficulty></span></p>
</li>
</template>
The template’s content property is a DocumentFragment, a lightweight container for nodes that isn’t part of the page. Clone it, fill in the clone, and insert it.
const template = document.querySelector('[data-resource-template]');
function createResourceItem(resource) {
const fragment = template.content.cloneNode(true);
const item = fragment.querySelector('[data-resource]');
item.dataset.resourceId = resource.id;
item.querySelector('[data-title]').textContent = resource.title;
item.querySelector('[data-category]').textContent = resource.category;
item.querySelector('[data-difficulty]').textContent = resource.difficulty;
return item;
}
cloneNode(true) copies the whole tree. Without true, you get an empty fragment. When you append item to the list, it moves out of the fragment and into the page.
You can also create an empty fragment with document.createDocumentFragment(), append items to it in a loop, and then insert the fragment into the page in one step. With replaceChildren and spread, you rarely need to do that by hand, but it’s useful to know what a fragment is when you see one.
Rendering conditionally and empty states
An empty list with no explanation looks broken. An empty state tells people what happened and what they can do about it. Put it in the HTML, hidden, and show it when the derived list has no items.
list.hidden = visible.length === 0;
emptyState.hidden = visible.length > 0;
The same pattern covers a status badge that only appears for some items, or a loading message while data arrives. Loading and error states need data that arrives later, so chapter 7 handles them. Each is just another condition you check before deciding what to show.
Updating in place or re-rendering
There are two ways to reflect a change. You can re-render the section, rebuilding all its elements from state, or you can find the one element that changed and update it.
Re-rendering is simpler and hard to get out of sync, because the screen is always rebuilt from the state. Its cost is that every element is new. Focus, text selection, scroll position inside the list, and anything typed into a field in the list are lost. The task board in chapters 3 and 4 ran into this when a checkbox lost focus.
Updating in place keeps the element, so focus stays put, but you have to make sure you update everything that depends on the change.
A reasonable rule is to re-render when the set or order of items changes, as with search, filter, and sort. Update in place when one item’s state changes and the person is interacting with that item, as with a favorite button.
Stable ids
Both approaches depend on ids. When a favorite button is pressed, data-resource-id on its list item tells you which object to change. After a re-render, the id lets you find the new element for the same item and restore focus. Frameworks use the same idea under the name “keys”: they compare the ids of old and new items to decide which elements to keep, move, or remove.
Don’t use an item’s position in the array as its id. Positions change every time someone sorts or filters, so position 2 refers to a different resource each time.
textContent, innerHTML, and cross-site scripting
innerHTML reads a string as HTML. That makes it tempting for rendering, because a template literal looks a lot like markup:
// UNSAFE: never build HTML from data that came from a person or a server.
item.innerHTML = `<h3>${resource.title}</h3>`;
This is fine until a title contains markup. If someone submits a resource titled <img src="x" onerror="alert(document.cookie)">, the browser creates a real image element, the image fails to load, and the onerror attribute runs as script. If those titles are stored and shown to other people, that code runs in every visitor’s browser with access to whatever the page can reach. That attack is called cross-site scripting, or XSS. A <script> element inserted with innerHTML won’t run, but event handler attributes like onerror do, so stripping out script tags doesn’t make a string safe.
textContent treats the string as text, every time. The same title renders as visible angle brackets and does nothing.
title.textContent = resource.title;
The safe habit is to keep structure in HTML (a template or createElement) and put data in with textContent and setAttribute. If you ever need to render HTML that came from elsewhere, such as formatted content from a CMS, run it through a well-maintained sanitizer first rather than writing your own filtering.
Smallest version: a searchable list
This is a complete page that renders an array of objects and filters it as you type.
<!-- index.html -->
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Resources</title>
<script type="module" src="app.js"></script>
</head>
<body>
<h1>Resources</h1>
<label for="search">Search resources</label>
<input id="search" type="search" data-search>
<ul data-resource-list></ul>
<p data-empty-state hidden>No resources match your search.</p>
</body>
</html>
// app.js
const state = {
query: '',
resources: [
{ id: 'resource-1', title: 'Accessible Modal Dialog Pattern' },
{ id: 'resource-2', title: 'Fluid Type Scales with clamp()' },
{ id: 'resource-3', title: 'Designing Empty States' },
],
};
const search = document.querySelector('[data-search]');
const list = document.querySelector('[data-resource-list]');
const emptyState = document.querySelector('[data-empty-state]');
function getVisibleResources(resources, query) {
const normalized = query.trim().toLowerCase();
return resources.filter((resource) =>
resource.title.toLowerCase().includes(normalized)
);
}
function createResourceItem(resource) {
const item = document.createElement('li');
item.dataset.resourceId = resource.id;
item.textContent = resource.title;
return item;
}
function render() {
const visible = getVisibleResources(state.resources, state.query);
list.replaceChildren(...visible.map(createResourceItem));
list.hidden = visible.length === 0;
emptyState.hidden = visible.length > 0;
}
search.addEventListener('input', (event) => {
state.query = event.target.value;
render();
});
render();
An empty query matches everything, because every string includes the empty string. That’s why the full list shows on load.
Accessibility check
Build the project version below, which adds favorite buttons, and then test it. Tab through the results. Each card has a button, and if every button’s text is “Favorite,” a screen reader announces “Favorite, toggle button, not pressed” for each one. Screen reader users often move through a page by pulling up a list of buttons, and in that list they see the same name repeated with nothing to tell the buttons apart.
The fix is to include the resource title in the button’s accessible name while keeping the visible label short:
<button type="button" aria-pressed="false" data-favorite>
Favorite <span class="visually-hidden" data-favorite-name></span>
</button>
The rendering function fills data-favorite-name with the title using textContent. The button is now announced as “Favorite Accessible Modal Dialog Pattern, toggle button, not pressed.” The visually-hidden class hides the span visually but keeps it in the accessibility tree; its CSS is in the project’s starter HTML. Avoid hidden or display: none here, because those remove the text for screen readers too.
Two more things to check. Press a favorite button with the keyboard and confirm focus stays on it, which works because the project updates that one button in place. Then search for nonsense, Tab to “Clear search and filters,” and press Enter. The button hides itself when the results come back, so the project moves focus to the search input. Otherwise focus would drop to the page body.
Debugging challenge: the order won’t come back
Write the sort helper without the copy, and have getVisibleResources sort before it filters:
function sortResources(items, sort) {
if (sort === 'title') {
return items.sort((a, b) => a.title.localeCompare(b.title));
}
return items;
}
function getVisibleResources(items, view) {
const query = view.query.trim().toLowerCase();
return sortResources(items, view.sort).filter((resource) =>
resource.title.toLowerCase().includes(query)
);
}
The symptom: the sort menu has “Recommended,” “Title,” and “Difficulty.” Choosing “Title” sorts alphabetically as expected. Choosing “Recommended” again leaves the list alphabetical, and the original order never returns until you reload.
“Recommended” means “the order of the original array,” so check whether the original array still has its original order. In the console, or with a temporary console.log in render, look at state.resources[0].title before and after choosing “Title.” It changes. Something modified the source data, and the only code that reorders anything is sortResources. Because the helper ran before filter, it received state.resources itself, and sort reordered that array in place.
Fix it by sorting a copy: [...resources].sort(...) or resources.toSorted(...). A good rule is that a function that calculates what to show should never change the data it was given.
This works because…
This works because the resources are an array of objects that never changes order, and the person’s choices are stored separately in state. Every time a choice changes, getVisibleResources filters and sorts a copy of the data, and renderResults rebuilds the list from a template using textContent, so titles are always text and never markup. Each list item carries its resource’s id, so when someone presses a favorite button the handler can find the right object, update it, and change that one button’s aria-pressed without re-rendering and losing focus.
Project: resource library
Build a resource library where people can search by title, filter by category, sort, mark favorites, and see a helpful empty state. Use at least twelve resources so that filtering and sorting have something to show.
Starter HTML
<!-- index.html -->
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Resource library</title>
<style>
.visually-hidden {
position: absolute;
width: 1px;
height: 1px;
margin: -1px;
padding: 0;
overflow: hidden;
clip-path: inset(50%);
white-space: nowrap;
border: 0;
}
[aria-pressed="true"] { font-weight: bold; }
</style>
<script type="module" src="app.js"></script>
</head>
<body>
<main>
<h1>Resource library</h1>
<search>
<label for="search">Search by title</label>
<input id="search" type="search" data-search>
<label for="category">Category</label>
<select id="category" data-category-filter>
<option value="all">All categories</option>
<option value="Accessibility">Accessibility</option>
<option value="CSS">CSS</option>
<option value="Content">Content</option>
</select>
<label for="sort">Sort by</label>
<select id="sort" data-sort>
<option value="recommended">Recommended</option>
<option value="title">Title</option>
<option value="difficulty">Difficulty</option>
</select>
</search>
<h2>Results</h2>
<p role="status" data-result-count></p>
<ul data-resource-list></ul>
<div data-empty-state hidden>
<p>No resources match. Try a shorter search or a different category.</p>
<button type="button" data-clear-filters>Clear search and filters</button>
</div>
</main>
<template data-resource-template>
<li data-resource>
<h3 data-title></h3>
<p><span data-category></span>, <span data-difficulty></span></p>
<button type="button" aria-pressed="false" data-favorite>
Favorite <span class="visually-hidden" data-favorite-name></span>
</button>
</li>
</template>
</body>
</html>
The <search> element marks the controls as a search landmark, so screen reader users can jump to them.
Data
// resources.js
export const resources = [
{ id: 'resource-1', title: 'Accessible Modal Dialog Pattern', category: 'Accessibility', difficulty: 'Intermediate', favorite: false },
{ id: 'resource-2', title: 'Fluid Type Scales with clamp()', category: 'CSS', difficulty: 'Beginner', favorite: false },
{ id: 'resource-3', title: 'Writing Useful Error Messages', category: 'Content', difficulty: 'Beginner', favorite: false },
{ id: 'resource-4', title: 'Focus Management in Single-Page Apps', category: 'Accessibility', difficulty: 'Advanced', favorite: false },
{ id: 'resource-5', title: 'Container Queries in Practice', category: 'CSS', difficulty: 'Intermediate', favorite: false },
{ id: 'resource-6', title: 'Designing Empty States', category: 'Content', difficulty: 'Beginner', favorite: false },
// Add at least six more in the same shape.
];
Key JavaScript
// app.js
import { resources } from './resources.js';
const difficultyRank = { Beginner: 1, Intermediate: 2, Advanced: 3 };
const defaultView = { query: '', category: 'all', sort: 'recommended' };
const state = { resources, ...defaultView };
const search = document.querySelector('[data-search]');
const categoryFilter = document.querySelector('[data-category-filter]');
const sortSelect = document.querySelector('[data-sort]');
const resultCount = document.querySelector('[data-result-count]');
const list = document.querySelector('[data-resource-list]');
const emptyState = document.querySelector('[data-empty-state]');
const clearButton = document.querySelector('[data-clear-filters]');
const template = document.querySelector('[data-resource-template]');
// Calculate
function sortResources(items, sort) {
if (sort === 'title') {
return [...items].sort((a, b) => a.title.localeCompare(b.title));
}
if (sort === 'difficulty') {
return [...items].sort(
(a, b) => difficultyRank[a.difficulty] - difficultyRank[b.difficulty]
);
}
return items;
}
function getVisibleResources(items, view) {
const query = view.query.trim().toLowerCase();
const matches = items.filter((resource) => {
const inCategory = view.category === 'all' || resource.category === view.category;
const matchesQuery = resource.title.toLowerCase().includes(query);
return inCategory && matchesQuery;
});
return sortResources(matches, view.sort);
}
function formatResultCount(total) {
return total === 1 ? '1 resource' : `${total} resources`;
}
// Change state
function findResource(id) {
return state.resources.find((resource) => resource.id === id);
}
function toggleFavorite(id) {
const resource = findResource(id);
if (!resource) return;
resource.favorite = !resource.favorite;
}
// Update the screen
function createResourceItem(resource) {
const fragment = template.content.cloneNode(true);
const item = fragment.querySelector('[data-resource]');
item.dataset.resourceId = resource.id;
item.querySelector('[data-title]').textContent = resource.title;
item.querySelector('[data-category]').textContent = resource.category;
item.querySelector('[data-difficulty]').textContent = resource.difficulty;
item.querySelector('[data-favorite-name]').textContent = resource.title;
item
.querySelector('[data-favorite]')
.setAttribute('aria-pressed', String(resource.favorite));
return item;
}
function renderResults() {
const visible = getVisibleResources(state.resources, state);
list.replaceChildren(...visible.map(createResourceItem));
list.hidden = visible.length === 0;
emptyState.hidden = visible.length > 0;
resultCount.textContent = formatResultCount(visible.length);
}
function renderControls() {
search.value = state.query;
categoryFilter.value = state.category;
sortSelect.value = state.sort;
}
// Handle events
search.addEventListener('input', () => {
state.query = search.value;
renderResults();
});
categoryFilter.addEventListener('change', () => {
state.category = categoryFilter.value;
renderResults();
});
sortSelect.addEventListener('change', () => {
state.sort = sortSelect.value;
renderResults();
});
list.addEventListener('click', (event) => {
const button = event.target.closest('[data-favorite]');
if (!button) return;
const id = button.closest('[data-resource]').dataset.resourceId;
toggleFavorite(id);
button.setAttribute('aria-pressed', String(findResource(id).favorite));
});
clearButton.addEventListener('click', () => {
Object.assign(state, defaultView);
renderControls();
renderResults();
search.focus();
});
renderResults();
{ resources, ...defaultView } builds the state object from the data plus a copy of the default choices, and Object.assign(state, defaultView) copies those defaults back when someone clears the filters. The favorite handler uses one listener on the list rather than one per button. That keeps working after every re-render, because the list itself is never replaced.
The status region announces the count each time the search changes, which for a fast typist can mean a lot of announcements. How screen readers handle rapid polite updates varies, so don’t rely on them to smooth it out. Chapter 7 shows how to debounce input so the count settles before it updates.
Done means
- Resources live in one array of objects, each with a stable
id, and that array keeps its original order no matter what you sort by. - Search, category, and sort combine, and “Recommended” restores the original order.
-
getVisibleResourcesis pure: it takes the data and the choices and returns a new array. - Every piece of resource text is written with
textContent. Test it by adding a resource whose title contains<img src="x" onerror="alert(1)">and confirming it shows as plain text. - Favorite buttons use
aria-pressed, have unique accessible names that include the title, and keep focus when pressed. - When nothing matches, the empty state explains why and offers a way out, and clearing moves focus to the search input.
- The result count is announced through a status region that exists when the page loads.
- Everything works with only a keyboard.
- You’ve written a “this works because…” paragraph covering the data model, the derived list, and when you re-render versus update in place.
Chapter 6 applies the same state-then-screen approach to forms, where the state includes what the person has typed, what’s valid, and whether a submission is in progress.
Found an error? Report it.