Free lesson · JavaScript Basic to Advance
The UI Layer: DOM, Events, Rendering
Where the User Sees What We Built
The data layer fetches and caches. Now we render it. This Topic builds the UI layer — the modules that turn state into DOM and handle user interactions.
// What we build in this Topic:
//
// ui/list.js — renders the list of items
// ui/filters.js — wires up the search input and sort dropdown
// ui/errors.js — renders error states with retry button
// lib/utils.js — debounce, classnames helpers
//
// Concepts demonstrated:
// - DOM manipulation (Topics 12.2, 12.3)
// - Event delegation (Topic 12.4)
// - DocumentFragment for batch updates (Topic 12.3)
// - Debouncing (Topic 5.8)
// - Modular code organization (Module 11)
The UI layer's job is to be a pure function of state — give it the state, it produces the DOM. It doesn't decide what to fetch. It doesn't manage caching. It just renders. This separation is what makes the rest of the app testable and the UI swappable.
utils.js: The Small Tools
Before building the UI, two utility functions that everything else uses.
// lib/utils.js
export function debounce(fn, delay) {
let timeoutId;
return function(...args) {
clearTimeout(timeoutId);
timeoutId = setTimeout(() => fn.apply(this, args), delay);
};
}
export function classnames(...args) {
return args
.flat()
.filter(arg => typeof arg === "string" && arg.length > 0)
.join(" ");
}
// Demo
const log = (msg) => console.log("Searched:", msg);
const debouncedLog = debounce(log, 100);
debouncedLog("a");
debouncedLog("ab");
debouncedLog("abc");
// Only "abc" runs, after 100ms
console.log(classnames("btn", "btn-primary", null, "active"));
// "btn btn-primary active" — nulls filtered out
debounce — from Topic 5.8 and the closure module 6. The pattern: each call cancels the previous timer and starts a new one. Only when there's a full delay with no new calls does the wrapped function run. The state — the timeout ID — lives in the closure between the outer function and the returned function. This is exactly the private-state-via-closure pattern.
classnames — a helper that builds CSS class strings from multiple arguments, ignoring empty/null values. Useful for conditional classes like classnames("btn", isActive && "active", isPrimary && "primary"). The .flat() lets callers pass arrays. The .filter() ignores non-strings and empty strings. The rest operator (Topic 10.2) lets us accept any number of arguments.
list.js: Rendering the Items
The list module takes state and produces DOM. We render imperatively — clear and rebuild — for clarity. Real production apps would use a framework or diffing approach for performance, but the imperative version makes the mechanics obvious.
// ui/list.js
export function renderList(container, state) {
if (!container) return;
// Clear existing content
container.innerHTML = "";
// Show loading state
if (state.loading) {
container.innerHTML = '<div class="loading">Loading...</div>';
return;
}
// Show empty state
if (state.items.length === 0) {
container.innerHTML = '<div class="empty">No items found.</div>';
return;
}
// Build the list using DocumentFragment for performance
const fragment = document.createDocumentFragment();
for (const item of state.items) {
const li = document.createElement("article");
li.className = "item";
li.dataset.itemId = item.id;
li.innerHTML = `
<h3>${escapeHtml(item.name)}</h3>
<p class="price">${item.price}</p>
<p class="rating">★ ${item.rating}</p>
`;
fragment.appendChild(li);
}
container.appendChild(fragment);
}
function escapeHtml(str) {
const div = document.createElement("div");
div.textContent = str;
return div.innerHTML;
}
// Demo
const container = document.createElement("div");
const state = {
loading: false,
items: [
{ id: 1, name: "Coffee Mug", price: 12, rating: 4.5 },
{ id: 2, name: "Notebook", price: 8, rating: 4.2 }
]
};
renderList(container, state);
console.log(container.innerHTML);
Walk through:
container.innerHTML = "" to clear. Faster than removing children one at a time. Topic 12.3 covered the trade-offs — for replacing content entirely, this is fine. For surgical updates, more careful manipulation is better.
DocumentFragment for the new content. Topic 12.3 covered this. We build the list in memory first, then append the fragment to the container with one DOM operation. For 100 items, this is one layout calculation instead of 100. The difference is significant.
dataset for item identification. Topic 12.2 covered this. data-item-id="42" becomes el.dataset.itemId in JavaScript. We'll use this in the event delegation in filters.js and the integration in Topic 15.5.
escapeHtml for safety. When inserting user-provided strings into HTML via innerHTML, you have to escape them. Otherwise a name like <script>alert('xss')</script> becomes executable. The trick we use — set as textContent, read as innerHTML — leverages the browser's built-in escaping.
There's a real tension here: innerHTML is convenient but risky. For most cases, building elements with createElement and textContent is safer. We chose innerHTML for clarity in this small example, but production code should lean toward createElement. We'll revisit this in Topic 15.6.
filters.js: Event Wiring
The filters module connects the form controls to handler functions.
// ui/filters.js
export function setupFilters({ onSearch, onSort, onItemClick }) {
const searchInput = document.getElementById("search");
const sortSelect = document.getElementById("sort");
const listContainer = document.getElementById("list");
// Search input — uses debounced handler
if (searchInput) {
searchInput.addEventListener("input", (event) => {
onSearch(event.target.value);
});
}
// Sort dropdown — immediate, no debounce needed
if (sortSelect) {
sortSelect.addEventListener("change", (event) => {
onSort(event.target.value);
});
}
// Item clicks — using event delegation
if (listContainer) {
listContainer.addEventListener("click", (event) => {
const item = event.target.closest(".item");
if (item && item.dataset.itemId) {
onItemClick(parseInt(item.dataset.itemId, 10));
}
});
}
}
// Demo setup (would normally be called by main.js)
console.log("setupFilters defined");
The patterns:
Direct event listeners on stable elements. The search input and sort dropdown are stable — they don't get re-rendered. We attach listeners directly to them. This is straightforward.
Event delegation for dynamic content. Topic 12.4 covered this in detail. The list items are dynamic — they get re-created every time renderList runs. We can't attach listeners to each item, because they don't exist yet and would need re-attachment every render.
Instead, we attach one listener to the stable list container. Inside the handler, we use event.target.closest(".item") to find which item was clicked. This works for current items and any future ones. Adding items doesn't require re-attaching listeners.
closest(). Topic 12.4 mentioned this. It walks up from the event target looking for the nearest ancestor matching the selector. Handles the case where the user clicked on a child of .item (like an <h3> inside it) — event.target would be the <h3>, but closest(".item") gives us the wrapping item.
Parsing dataset values. dataset values are always strings (Topic 12.2). We convert to a number with parseInt(value, 10). The radix 10 is important — without it, leading zeros could be interpreted as octal in older engines.
errors.js: Error Rendering
A small module for displaying errors with a retry button.
// ui/errors.js
export function showError(container, error, onRetry) {
if (!container) return;
if (!error) {
container.innerHTML = "";
container.style.display = "none";
return;
}
container.style.display = "block";
container.innerHTML = `
<div class="error" role="alert">
<p>Something went wrong: ${escapeHtml(error.message)}</p>
<button class="retry-btn">Try again</button>
</div>
`;
// Wire up the retry button
const retryBtn = container.querySelector(".retry-btn");
if (retryBtn && onRetry) {
retryBtn.addEventListener("click", onRetry);
}
}
function escapeHtml(str) {
const div = document.createElement("div");
div.textContent = str;
return div.innerHTML;
}
// Demo
const errorContainer = document.createElement("div");
const sampleError = new Error("Network request failed");
showError(errorContainer, sampleError, () => console.log("Retry clicked"));
console.log(errorContainer.innerHTML);
A few patterns to note:
role="alert" for accessibility. Screen readers announce alerts when they appear. For error states, this matters — users with assistive tech need to know something went wrong. Real frontend interview rubrics include accessibility, even when not explicitly asked.
Conditional rendering via the if (!error) early return. When there's no error, we clear the container and hide it. This is the same pattern as in renderList's loading/empty handling.
onRetry callback. The error module doesn't know what retry means — it just wires up the click. The caller (in main.js or the state layer in Topic 15.5) provides the actual retry logic. This is composition again: separating "what the button does" from "how the button is shown."
Interactive animation: The edges, designed: what the loading, empty and error states each show, down to the retry button.
How the UI Connects to State
In Topic 15.5, we'll wire everything together with a state store. The flow will be:
// Conceptually (full integration in Topic 15.5):
const store = new Store(initialState);
// UI subscribes to state changes
store.subscribe(state => {
renderList(document.getElementById("list"), state);
showError(document.getElementById("status"), state.error, () => store.retry());
});
// User actions update state
setupFilters({
onSearch: debounce(query => store.update({ query }), 200),
onSort: sort => store.update({ sort }),
onItemClick: id => store.update({ selectedId: id })
});
This is the unidirectional data flow that frameworks like React enforce. State changes trigger renders. User actions trigger state changes. The components don't talk to each other directly — they communicate through the state.
What We Built
Four small modules. Each has a single responsibility:
utils.js— generic helpers (debounce, classnames)list.js— render items from statefilters.js— wire UI controls to handlerserrors.js— render error states with retry
The total code is small. The thinking is in the boundaries:
- The UI never fetches anything (that's the data layer)
- The UI never decides what data to show (that's the state)
- The UI doesn't track caches or retry logic
- The UI just renders state and emits events
This separation is what makes the codebase scale. Adding a new filter doesn't require touching the list rendering. Changing the cache strategy doesn't require touching the UI. Each module changes for one reason.
Common Pitfalls Avoided
A few patterns we deliberately avoided that often appear in less-careful code:
Direct DOM mutation from data-layer code. Some implementations have the API class call document.getElementById(...) directly. This couples the data layer to the DOM and makes testing impossible. We keep them separate.
Re-attaching listeners on every render. Re-rendering the list with new items would normally require re-attaching click handlers. Event delegation avoids this entirely — one listener on the stable container handles all current and future items.
Mixing concerns in single modules. A common bug is a single list.js that fetches, caches, renders, and handles clicks. It works, but every change touches the same file. Splitting concerns gives each module one reason to change.
Try It
Exercise: Add a "view mode" toggle to the dashboard — switch between list view (current) and grid view.
- Add a toggle button to
index.htmlwithid="view-mode"- In
filters.js, wire up the click to call a newonViewModeChangehandler- In
list.js, accept aviewModefrom the state and render the items as either a<ul>or a CSS grid- In Topic 15.5's state store (or for now, just inline), track which mode is active
The exercise gets you using state, events, and rendering together — the same trio you'll use for every feature in a real app.