JavaScript Practice: a Frontend App Without a Framework
AI-assistedDrafted with AI assistance and reviewed before publishing; examples are version-pinned. Verify against official docs when in doubt.
A framework is not a requirement. Three ideas — a single source of truth, event delegation, and rendering from state — carry an application of a few hundred lines a long way. The example here is a reading list: add a book, mark it read, delete it, filter, and keep everything after a refresh.
Structure and features
reading-list/
index.html
app.js
styles.css
- Add a title, with empty and duplicate validation.
- Toggle “read”, filter by all / unread / read.
- Delete an entry, with confirmation.
- Persist to
localStorageso a refresh is harmless. - Keyboard operable, with an
aria-livesummary for screen readers.
HTML skeleton
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Reading list</title>
<link rel="stylesheet" href="./styles.css" />
<script type="module" src="./app.js"></script>
</head>
<body>
<main class="app">
<h1>Reading list</h1>
<form id="add-form" novalidate>
<label for="title">Title</label>
<input id="title" name="title" required maxlength="80" autocomplete="off" />
<button type="submit">Add</button>
<p id="form-error" class="error" role="alert" hidden></p>
</form>
<div class="filters" role="group" aria-label="Filter">
<button type="button" data-filter="all" aria-pressed="true">All</button>
<button type="button" data-filter="open" aria-pressed="false">Unread</button>
<button type="button" data-filter="done" aria-pressed="false">Read</button>
</div>
<p id="summary" role="status" aria-live="polite"></p>
<p id="notice" class="notice" role="status" hidden></p>
<ul id="list" class="list"></ul>
<p id="empty" class="empty" hidden>Nothing here yet. Add a book.</p>
</main>
</body>
</html>
type="module" defers execution until the document is parsed, so there is no need to wait for DOMContentLoaded and no risk of querying nodes that do not exist yet.
One state object and one render function
The rule the whole file follows: state is the only truth, every change calls render(), and nothing writes to the DOM directly except render().
// app.js
const STORAGE_KEY = "reading-list.v2";
const state = {
books: load(),
filter: "all",
};
function isValidBook(item) {
return (
item &&
typeof item.id === "string" &&
typeof item.title === "string" &&
typeof item.done === "boolean"
);
}
function load() {
try {
const raw = localStorage.getItem(STORAGE_KEY);
const parsed = raw ? JSON.parse(raw) : [];
return Array.isArray(parsed) ? parsed.filter(isValidBook) : [];
} catch {
return [];
}
}
function save() {
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(state.books));
return true;
} catch {
return false; // private mode or a full quota
}
}
A storage failure must reach the user, because from then on the list lives only in memory:
const noticeEl = document.querySelector("#notice");
function notice(message) {
noticeEl.textContent = message;
noticeEl.hidden = false;
clearTimeout(notice.timer);
notice.timer = setTimeout(() => (noticeEl.hidden = true), 4000);
}
Validating on read is not paranoia: stored data outlives your code, users edit it, and a single malformed entry is enough to break rendering for the whole list.
const listEl = document.querySelector("#list");
const emptyEl = document.querySelector("#empty");
const summaryEl = document.querySelector("#summary");
function visibleBooks() {
if (state.filter === "open") return state.books.filter((book) => !book.done);
if (state.filter === "done") return state.books.filter((book) => book.done);
return state.books;
}
function render() {
const books = visibleBooks();
const frag = document.createDocumentFragment();
for (const book of books) {
const li = document.createElement("li");
li.className = "item";
li.dataset.id = book.id;
const label = document.createElement("label");
const checkbox = document.createElement("input");
checkbox.type = "checkbox";
checkbox.checked = book.done;
checkbox.dataset.action = "toggle";
const text = document.createElement("span");
text.textContent = book.title; // never innerHTML
text.classList.toggle("done", book.done);
label.append(checkbox, text);
const remove = document.createElement("button");
remove.type = "button";
remove.dataset.action = "delete";
remove.textContent = "Delete";
remove.setAttribute("aria-label", `Delete “${book.title}”`);
li.append(label, remove);
frag.append(li);
}
listEl.replaceChildren(frag);
emptyEl.hidden = books.length > 0;
const done = state.books.filter((book) => book.done).length;
summaryEl.textContent = `${state.books.length} books, ${done} read`;
}
Full re-render is fine for hundreds of rows; incremental updates only pay off past the thousands. replaceChildren swaps the contents in one operation, which avoids the “old nodes stay behind” bug that comes from innerHTML = "" plus manual appends.
Event delegation
Rows are created and destroyed constantly, so the listener lives on the container:
listEl.addEventListener("click", (event) => {
const target = event.target.closest("[data-action]");
if (!target || !listEl.contains(target)) return;
const id = target.closest(".item")?.dataset.id;
if (!id) return;
if (target.dataset.action === "delete") {
const book = state.books.find((item) => item.id === id);
if (!book || !confirm(`Delete “${book.title}”?`)) return;
state.books = state.books.filter((item) => item.id !== id);
} else if (target.dataset.action === "toggle") {
const book = state.books.find((item) => item.id === id);
if (!book) return;
book.done = target.checked;
}
if (!save()) notice("Change applied for this session only — storage is blocked or full");
render();
});
click on a checkbox reads the updated checked already; change works too but fires after the browser has finished its own handling, and the order differs for a space key press.
Form validation
const form = document.querySelector("#add-form");
const titleInput = document.querySelector("#title");
const errorEl = document.querySelector("#form-error");
form.addEventListener("submit", (event) => {
event.preventDefault();
const title = titleInput.value.trim();
if (!title) {
showError("A title is required");
titleInput.focus();
return;
}
if (state.books.some((book) => book.title.toLowerCase() === title.toLowerCase())) {
showError("That book is already on the list");
titleInput.select();
return;
}
state.books.unshift({
id: crypto.randomUUID(),
title,
done: false,
createdAt: new Date().toISOString(),
});
if (!save()) notice("Added, but not saved — storage is blocked or full");
render();
hideError();
form.reset();
titleInput.focus(); // keyboard users keep their place
});
function showError(message) {
errorEl.textContent = message;
errorEl.hidden = false;
titleInput.setAttribute("aria-invalid", "true");
}
function hideError() {
errorEl.hidden = true;
titleInput.removeAttribute("aria-invalid");
}
titleInput.addEventListener("input", hideError);
novalidate turns off the browser bubbles so the app owns the message. The alternative is to keep the native validation and skip the custom error paragraph — but do not run both, or the user sees two competing messages.
crypto.randomUUID() needs a secure context: it works on https:// and localhost, and is undefined on plain HTTP.
Filter buttons
const filterGroup = document.querySelector(".filters");
filterGroup.addEventListener("click", (event) => {
const btn = event.target.closest("[data-filter]");
if (!btn) return;
state.filter = btn.dataset.filter;
for (const button of filterGroup.querySelectorAll("[data-filter]")) {
button.setAttribute("aria-pressed", String(button === btn));
}
render();
});
aria-pressed is both the accessible state and a styling hook — [aria-pressed="true"] in CSS means no extra class to keep in sync.
Styling, minimally
.done { text-decoration: line-through; opacity: 0.6; }
.item { display: flex; align-items: center; justify-content: space-between; gap: 0.75rem; }
:focus-visible { outline: 2px solid currentColor; outline-offset: 2px; }
Three things are not decoration: hit targets large enough to tap, a visible :focus-visible outline, and a “read” state that is not conveyed by colour alone.
Migrating stored data
The shape of stored data will change. Keep a version and put migration logic in one place:
const VERSION = 2;
function migrate(raw) {
const data = raw ? JSON.parse(raw) : [];
// v1 stored an array of title strings
if (Array.isArray(data) && typeof data[0] === "string") {
return data.map((title) => ({ id: crypto.randomUUID(), title, done: false }));
}
return Array.isArray(data) ? data : [];
}
Without a migration path, an upgrade shows an empty list and the user concludes the app lost their data.
Accessibility checks that matter
- Use
buttonandinput, notdivwithonclick; keyboard support comes for free. - Keep the label associated via
for/id, and keeparia-invalidin sync with the visible error. role="status"plusaria-live="polite"announces the count without interrupting.aria-labelon icon-only buttons, phrased with the item’s title so the list is navigable.
Common mistakes
1. Writing to the DOM and to state
Two sources of truth always diverge. Every interaction follows state → save() → render().
2. Interpolating a title into innerHTML
Titles are user input; <img src=x onerror=…> executes. Render text with textContent.
3. Ignoring localStorage failures
Private browsing and a full quota throw QuotaExceededError. Catch it and tell the user the change was not persisted.
4. Saving on every keystroke
Writing to storage on each input event is slow on large lists. Save on submit, or debounce.
5. Deleting without confirmation
confirm is not elegant but it is effective; a custom dialog must move focus into it and close on Escape.
6. Mouse-only interaction
A div with a click handler cannot be reached by keyboard. Native controls, or explicit tabindex plus keydown.
7. No empty state
Deleting the last item leaves a blank page that looks broken. #empty exists for that.
Summary
- One state object plus one
render()is the smallest structure that stays correct. - Delegate events, and read
data-actionand the row id fromclosest(). - Render user text with
textContent; validate stored data and leave a migration path. - Own the validation messages, and keep
aria-invalidin sync with them. - Accessibility is not polish: native controls, focus styles, live announcements.
Next: the JavaScript path ends here — replay a chapter you skipped, or apply the same structure in another language.