JavaScript: Events, 10–17
Listening, bubbling and delegation.
Exercises 10 to 17 of 21
10 Act on a double click
easyListen for a double click. Then work out what else fires on the way there, and why that is a problem.
Stuck? Three hints, smallest first
- 1. The event is
dblclick, spelled without a hyphen or a capital. - 2. A double click also fires
clicktwice, first. - 3. So if both are handled, the single-click action runs before the double-click one.
allow-scripts only, so it cannot reach this page.const target = el('button', { id: 'target', type: 'button' }, 'Click me');
document.body.appendChild(target);
const log = [];
let renames = 0;
target.addEventListener('click', () => log.push('click'));
target.addEventListener('dblclick', () => { log.push('dblclick'); renames++; });
// A real double click sends click, click, dblclick — in that order.
const doubleClick = () => {
fire(target, 'click');
fire(target, 'click');
fire(target, 'dblclick');
};
fire(target, 'click');
console.log('one click :', log);
log.length = 0;
doubleClick();
console.log('double :', log, '<- click fired twice BEFORE dblclick');
// If a single click does something visible, this is the fix: wait long enough to
// know a second click is not coming.
const clocked = [];
let pending = null;
const smart = {
click() {
clearTimeoutStub(pending);
pending = setTimeoutStub(() => clocked.push('single'), 250);
},
dblclick() {
clearTimeoutStub(pending); // cancel the pending single
clocked.push('double');
},
};
// A tiny injected clock, so the wait costs nothing.
let now = 0;
let jobs = [];
let nextId = 1;
function setTimeoutStub(fn, ms) { const id = nextId++; jobs.push({ id, fn, at: now + ms }); return id; }
function clearTimeoutStub(id) { jobs = jobs.filter((j) => j.id !== id); }
function advance(ms) {
now += ms;
const due = jobs.filter((j) => j.at <= now);
jobs = jobs.filter((j) => j.at > now);
for (const j of due) j.fn();
}
smart.click(); advance(300);
smart.click(); smart.click(); smart.dblclick(); advance(300);
console.log('with a delay:', clocked, '<- one single, one double, no false single');
Output
12 checks that prove this answer
You do not have to read these. They are part of the answer panel above, so pressing
Run there executes all 12 of them and prints ok for each.
That output is the proof, and the build fails if a single check fails.
test('a single click fires click once', (() => { log.length = 0; fire(target, 'click'); return log; })(), ['click']);
test('a double click fires three events', (() => { log.length = 0; doubleClick(); return log.length; })(), 3);
test('and click comes first, twice', log.slice(0, 2), ['click', 'click']);
test('with dblclick last', log.at(-1), 'dblclick');
test('so a click handler runs during a double click', log.filter((l) => l === 'click').length, 2);
test('the dblclick handler ran once', renames, 2);
test('the event name has no capital or hyphen', 'dblclick', 'dblclick');
test('the delayed version reports one single click', clocked.filter((c) => c === 'single').length, 1);
test('and one double', clocked.filter((c) => c === 'double').length, 1);
test('with no false single before the double', clocked, ['single', 'double']);
test('cancelling the pending single is what does it', jobs.length, 0);
test('a dblclick alone still works', (() => { log.length = 0; fire(target, 'dblclick'); return log; })(), ['dblclick']);
A double click is not a separate gesture the browser tells you about instead of clicks — it fires click, click, then dblclick, and the assertions pin down that order. So if a single click does anything visible, a double click does it twice before the double-click handler gets a turn. The classic symptom is a list where double-clicking to rename also opens the item twice.
The fix is to delay the single-click action by roughly 250ms and cancel it if dblclick arrives. That is shown above with an injected clock so the wait costs no real time. It is worth saying that the delay is a real cost: every single click now feels slightly slow, which is why most interfaces avoid giving the same element both behaviours.
11 Event delegation for a growing list
fairHandle clicks on list items, including items added after the listener was attached, using one listener.
Stuck? Three hints, smallest first
- 1. Attach the listener to the parent. Events from the children bubble up to it.
- 2.
event.targetis what was clicked; it may be a nested element, not the item itself. - 3.
event.target.closest(‘li’)is how you find the item from whatever was actually hit.
allow-scripts only, so it cannot reach this page.const list = el('ul', { id: 'list' }, [
el('li', {}, ['Ojota ', el('b', {}, 'hub')]), // has a nested element
el('li', {}, 'Berger'),
]);
document.body.appendChild(list);
const clicks = [];
let listeners = 1; // exactly one, for the whole list
// One listener on the parent. Children's clicks bubble up to it, so items added
// later are handled with no extra work.
list.addEventListener('click', (event) => {
// target may be the <b> inside the item, so walk up to the item itself.
const item = event.target.closest('li');
if (!item || !list.contains(item)) return;
clicks.push({ text: item.textContent.trim(), hit: event.target.tagName });
});
const addItem = (text) => {
const li = el('li', {}, text);
list.appendChild(li); // no listener attached to it
return li;
};
fire(list.children[1], 'click');
console.log('clicked an existing item:', clicks.at(-1));
// Clicking the nested <b> inside the first item.
fire(list.children[0].querySelector('b'), 'click');
console.log('clicked a nested element:', clicks.at(-1), '<- closest() found the li');
const added = addItem('Ibadan');
fire(added, 'click');
console.log('clicked an item added later:', clicks.at(-1), '<- still handled');
// Clicking the list itself, but not an item.
fire(list, 'click');
console.log('clicked the list background: entries still', clicks.length);
Output
13 checks that prove this answer
You do not have to read these. They are part of the answer panel above, so pressing
Run there executes all 13 of them and prints ok for each.
That output is the proof, and the build fails if a single check fails.
test('an existing item is handled', clicks[0].text, 'Berger');
test('a click on a nested element still finds the item', clicks[1].text, 'Ojota hub');
test('even though the target was the inner element', clicks[1].hit, 'B');
test('an item added afterwards is handled too', clicks[2].text, 'Ibadan');
test('with no listener of its own', added.listenerCount('click'), 0);
test('while the parent has exactly one', list.listenerCount('click'), 1);
test('there is still only one listener for the list', listeners, 1);
test('clicking the list but not an item is ignored', clicks.length, 3);
test('closest returns the element itself when it matches', list.children[1].closest('li') === list.children[1], true);
test('and null when nothing matches', list.closest('li'), null);
test('ten more items need no more listeners', (() => { for (let i = 0; i < 10; i++) addItem(`Stop ${i}`); return list.children.length; })(), 13);
test('and they all work', (() => { const before = clicks.length; for (const li of [...list.children]) fire(li, 'click'); return clicks.length - before; })(), 13);
test('still one listener', listeners, 1);
Delegation solves two problems at once. Items that do not exist yet are handled, because the listener is on the parent and the event bubbles up to it — the assertions add ten items and confirm all of them work with no new listeners. And one listener replaces however many items you have, which matters for a long list.
event.target.closest('li') is the part people leave out. target is the deepest element under the pointer, so clicking the bold text inside an item gives you the <b>, not the <li> — the assertion checks hit is 'B' while the item was still identified correctly. closest walks up from the target until it finds a match, returning the element itself if it already matches, or null. The list.contains(item) check afterwards is worth keeping too, since closest will happily walk past your container.
12 Stop a form submitting and read the values
easyPrevent a form's default submission and record the input values instead.
Stuck? Three hints, smallest first
- 1.
event.preventDefault()in thesubmithandler stops the navigation. - 2. Listen for
submiton the form, notclickon the button. Enter in a field submits too. - 3.
new FormData(form)reads every named field at once.
allow-scripts only, so it cannot reach this page.const form = el('form', { id: 'form' }, [
el('input', { name: 'from', value: 'Ojota' }),
el('input', { name: 'to', value: 'Abuja' }),
el('input', { name: 'seats', type: 'number', value: '2' }),
el('button', { type: 'submit' }, 'Submit'),
]);
document.body.appendChild(form);
const logged = [];
// submit on the FORM, not click on the button: pressing Enter in a text field
// submits as well, and a click listener would miss it entirely.
form.addEventListener('submit', (event) => {
event.preventDefault();
const data = {};
for (const field of event.target.elements) {
if (!field.name) continue; // the submit button has no name
data[field.name] = field.value; // always a string
}
logged.push(data);
});
const event = new Event('submit', { bubbles: true, cancelable: true });
form.dispatchEvent(event);
console.log('default prevented:', event.defaultPrevented);
console.log('values logged :', logged[0]);
console.log('seats is a', typeof logged[0].seats, 'not a number');
// preventDefault only works on a cancelable event.
const notCancelable = new Event('submit', { bubbles: true, cancelable: false });
notCancelable.preventDefault();
console.log('preventDefault on a non-cancelable event:', notCancelable.defaultPrevented);
Output
12 checks that prove this answer
You do not have to read these. They are part of the answer panel above, so pressing
Run there executes all 12 of them and prints ok for each.
That output is the proof, and the build fails if a single check fails.
test('the default was prevented', event.defaultPrevented, true);
test('one submission was logged', logged.length, 1);
test('every named field is captured', Object.keys(logged[0]), ['from', 'to', 'seats']);
test('the values are right', logged[0].from, 'Ojota');
test('the unnamed submit button is skipped', Object.keys(logged[0]).includes(''), false);
test('a number input still gives a string', typeof logged[0].seats, 'string');
test('so it needs converting before arithmetic', Number(logged[0].seats) + 1, 3);
test('while the string would concatenate', logged[0].seats + 1, '21');
test('dispatchEvent returns false when the default was prevented', form.dispatchEvent(new Event('submit', { bubbles: true, cancelable: true })), false);
test('a second submission logs again', logged.length, 2);
test('preventDefault does nothing on a non-cancelable event', notCancelable.defaultPrevented, false);
test('changing a value changes what is logged', (() => { form.elements[0].value = 'Berger'; fire(form, 'submit'); return logged.at(-1).from; })(), 'Berger');
Two habits. Listen for submit on the form rather than click on the button, because Enter inside a text field submits the form without any click happening — a click listener silently misses that path. And call preventDefault() first, before any work that could throw, so an error later cannot leave the page navigating away.
The typeof assertions are the detail worth carrying: input.value is a string even for type="number", so seats + 1 gives "21" rather than 3. Convert at the boundary, as soon as the value leaves the DOM.
preventDefault() also only does anything on a cancelable event, which the last assertion shows. Events you dispatch yourself default to cancelable: false, so a hand-made event that ignores preventDefault is usually a missing option rather than a broken handler.
13 Bubbling, and stopping it
fairShow an event travelling up through nested elements, then stop it partway with stopPropagation.
Stuck? Three hints, smallest first
- 1. A click on the innermost element runs the handlers of every ancestor, innermost first.
- 2.
stopPropagation()stops the travel but still runs the other handlers on the same element. - 3.
stopImmediatePropagation()stops those too.
allow-scripts only, so it cannot reach this page.const child = el('button', { id: 'child', type: 'button' }, 'child');
const parent = el('div', { id: 'parent' }, ['parent', child]);
const grandparent = el('div', { id: 'grandparent' }, ['grandparent', parent]);
document.body.appendChild(grandparent);
let log = [];
let stopAtParent = false;
child.addEventListener('click', () => log.push('child'));
parent.addEventListener('click', (event) => {
log.push('parent');
if (stopAtParent) event.stopPropagation();
});
parent.addEventListener('click', () => log.push('parent, second listener'));
grandparent.addEventListener('click', () => log.push('grandparent'));
document.body.addEventListener('click', () => log.push('body'));
const clickChild = () => { log = []; fire(child, 'click'); return log; };
console.log('bubbling all the way :', clickChild());
stopAtParent = true;
const stopped = clickChild();
console.log('stopped at the parent:', stopped);
console.log('<- note the second listener on the parent STILL ran');
// stopImmediatePropagation also stops the siblings on the same element.
stopAtParent = false;
const immediateLog = [];
const hard = el('button', { type: 'button' }, 'hard stop');
const hardWrap = el('div', {}, hard);
document.body.appendChild(hardWrap);
hard.addEventListener('click', (e) => { immediateLog.push('first'); e.stopImmediatePropagation(); });
hard.addEventListener('click', () => immediateLog.push('second'));
hardWrap.addEventListener('click', () => immediateLog.push('wrapper'));
fire(hard, 'click');
// Snapshotted now: later assertions fire more clicks, and this claim is about
// this one click.
const immediate = [...immediateLog];
console.log('stopImmediatePropagation:', immediate, '<- the second listener never ran');
Output
13 checks that prove this answer
You do not have to read these. They are part of the answer panel above, so pressing
Run there executes all 13 of them and prints ok for each.
That output is the proof, and the build fails if a single check fails.
test('a click on the child runs every ancestor handler', clickChild(), ['child', 'parent', 'parent, second listener', 'grandparent', 'body']);
test('innermost first', clickChild()[0], 'child');
test('outermost last', clickChild().at(-1), 'body');
test('stopPropagation stops it travelling further out', stopped.includes('grandparent'), false);
test('and the body never hears it', stopped.includes('body'), false);
test('but the child and parent did run', stopped.slice(0, 2), ['child', 'parent']);
test('and the parent\u2019s OTHER listener still ran', stopped.includes('parent, second listener'), true);
test('which is exactly what stopImmediatePropagation prevents', immediate, ['first']);
test('the second listener on that element never ran', immediate.includes('second'), false);
test('nor did the wrapping element', immediate.includes('wrapper'), false);
test('stopping propagation is not preventing the default', (() => { const e = new Event('click', { bubbles: true, cancelable: true }); e.stopPropagation(); return e.defaultPrevented; })(), false);
test('target stays the element that was clicked throughout', (() => { let targets = []; const fn = (e) => targets.push(e.target === child); grandparent.addEventListener('click', fn); fire(child, 'click'); grandparent.removeEventListener('click', fn); return targets; })(), [true]);
test('while currentTarget changes per handler', (() => { let seen = null; const fn = (e) => { seen = e.currentTarget === grandparent; }; grandparent.addEventListener('click', fn); fire(child, 'click'); grandparent.removeEventListener('click', fn); return seen; })(), true);
Bubbling runs handlers from the clicked element outwards to the document. The assertion listing all five in order is the whole mental model.
The distinction people miss: stopPropagation() stops the event moving to the next element, but every other listener on the current element still runs — the assertion shows the parent's second listener firing after the first called stopPropagation. stopImmediatePropagation() is the one that stops those as well.
And stopping propagation is not the same as preventDefault(). One controls where the event travels; the other cancels the browser's built-in reaction. A checkbox whose click handler calls only stopPropagation still ticks. Reaching for stopPropagation as a general habit is worth avoiding: it silently breaks any delegated listener higher up, including ones added later by code you did not write.
14 The capture phase
fairAdd listeners to a parent and a child in both phases, and show the full order: down the tree, at the target, then back up.
Stuck? Three hints, smallest first
- 1. A third argument of
trueregisters for the capture phase. - 2. Capture runs outermost first — the opposite of bubbling.
- 3. On the target element itself, listeners run in the order they were added, regardless of phase.
allow-scripts only, so it cannot reach this page.const target = el('button', { id: 'target', type: 'button' }, 'target');
const inner = el('div', { id: 'inner' }, ['inner', target]);
const outer = el('div', { id: 'outer' }, ['outer', inner]);
document.body.appendChild(outer);
const log = [];
// The third argument, true, means "capture": run on the way DOWN.
outer.addEventListener('click', () => log.push('outer capture'), true);
inner.addEventListener('click', () => log.push('inner capture'), true);
// On the target itself both are registered, and they run in the order added.
target.addEventListener('click', () => log.push('target capture'), true);
target.addEventListener('click', () => log.push('target bubble'));
// No third argument means bubble: run on the way back UP.
inner.addEventListener('click', () => log.push('inner bubble'));
outer.addEventListener('click', () => log.push('outer bubble'));
fire(target, 'click');
for (const line of log) console.log(line);
// A capture listener can stop the event before the target ever sees it.
const blocked = [];
const b = el('button', { type: 'button' }, 'blocked');
const wrap = el('div', {}, b);
document.body.appendChild(wrap);
wrap.addEventListener('click', (e) => { blocked.push('wrapper capture'); e.stopPropagation(); }, true);
b.addEventListener('click', () => blocked.push('button'));
fire(b, 'click');
console.log('capture can stop it early:', blocked, '<- the button never heard its own click');
Output
11 checks that prove this answer
You do not have to read these. They are part of the answer panel above, so pressing
Run there executes all 11 of them and prints ok for each.
That output is the proof, and the build fails if a single check fails.
test('capture runs outermost first', log[0], 'outer capture');
test('then inwards', log[1], 'inner capture');
test('then the target', log.slice(2, 4), ['target capture', 'target bubble']);
test('then bubbling outwards', log.slice(4), ['inner bubble', 'outer bubble']);
test('six handlers ran in total', log.length, 6);
test('capture is the exact reverse of bubble', [log[0], log[1]], [log.at(-1), log.at(-2)].map((s) => s.replace('bubble', 'capture')));
test('a capture listener can stop the event before the target', blocked, ['wrapper capture']);
test('so the target never ran', blocked.includes('button'), false);
test('the third argument true is what selects capture', (() => { const log2 = []; const p = el('div', {}, el('button', {}, 'x')); document.body.appendChild(p); p.addEventListener('click', () => log2.push('cap'), true); p.addEventListener('click', () => log2.push('bub'), false); fire(p.children[0], 'click'); return log2; })(), ['cap', 'bub']);
test('an options object works too', (() => { const log3 = []; const p = el('div', {}, el('button', {}, 'x')); document.body.appendChild(p); p.addEventListener('click', () => log3.push('cap'), { capture: true }); fire(p.children[0], 'click'); return log3; })(), ['cap']);
test('capture and bubble listeners are removed separately', (() => { const fn = () => {}; const d = el('div', {}); d.addEventListener('click', fn, true); d.addEventListener('click', fn, false); d.removeEventListener('click', fn, true); return d.listenerCount('click'); })(), 1);
Every event makes three passes: capture from the document down to the target, the target itself, then bubble back up. Most code only ever uses the bubble phase, which is why the capture phase feels obscure — but it is the same journey, just the first half.
Two places it earns its keep. A capture listener on an ancestor sees the event before the target does, so it can block it entirely — the assertion shows a button never receiving its own click. And it is the only way to intercept events that do not bubble, like focus, from a parent.
The detail that catches people: capture and bubble registrations are stored separately, so removeEventListener(type, fn) without the matching third argument does not remove a capture listener. The last assertion checks that one listener survives after removing only the capture one.
15 Debounce a text input
hardWait until typing has stopped before doing expensive work, so a search box does not fire a request per keystroke.
Stuck? Three hints, smallest first
- 1. Keep the timer id and clear it at the start of every call.
- 2. Debounce waits for quiet. Throttle runs at a fixed rate. They are not the same.
- 3. Take the timer functions as arguments so the delay can be tested without waiting.
allow-scripts only, so it cannot reach this page.// ---- the stand-in ----------------------------------------------------------
// An injected clock, so a 300ms wait costs no real time.
function makeClock() {
let now = 0;
let jobs = [];
let nextId = 1;
return {
setTimeout(fn, ms) { const id = nextId++; jobs.push({ id, fn, at: now + ms }); return id; },
clearTimeout(id) { jobs = jobs.filter((j) => j.id !== id); },
pending: () => jobs.length,
advance(ms) {
now += ms;
const due = jobs.filter((j) => j.at <= now).sort((a, b) => a.at - b.at);
jobs = jobs.filter((j) => j.at > now);
for (const job of due) job.fn();
},
};
}
// ---- the answer ------------------------------------------------------------
function debounce(fn, wait, clock = { setTimeout, clearTimeout }) {
let timer = null;
return (...args) => {
clock.clearTimeout(timer); // cancel the previous plan
timer = clock.setTimeout(() => fn(...args), wait);
};
}
const clock = makeClock();
const box = el('input', { id: 'box' });
document.body.appendChild(box);
let keystrokes = 0;
const searches = [];
const search = debounce((value) => searches.push(value), 300, clock);
box.addEventListener('input', (event) => {
keystrokes++;
search(event.target.value);
});
const type = (text) => { box.value = text; fire(box, 'input'); };
// Typing "Abuja" one letter at a time, 50ms apart.
for (const partial of ['A', 'Ab', 'Abu', 'Abuj', 'Abuja']) {
type(partial);
clock.advance(50);
}
const keystrokesAfterWord = keystrokes; // snapshot: more typing happens below
console.log('mid-typing : keystrokes', keystrokes, '| searches', searches.length, '| pending', clock.pending());
clock.advance(300);
console.log('after quiet: keystrokes', keystrokes, '| searches', searches);
// A pause in the middle produces two searches.
type('L'); clock.advance(400);
type('La'); clock.advance(400);
console.log('with pauses:', searches);
Output
13 checks that prove this answer
You do not have to read these. They are part of the answer panel above, so pressing
Run there executes all 13 of them and prints ok for each.
That output is the proof, and the build fails if a single check fails.
test('five keystrokes were seen while typing the word', keystrokesAfterWord, 5);
test('but they produced a single search', searches.filter((s) => s === 'Abuja').length, 1);
test('and it used the final value, not an early one', searches[0], 'Abuja');
test('no intermediate value was searched', searches.includes('Ab'), false);
test('a pause between words produces separate searches', searches, ['Abuja', 'L', 'La']);
test('nothing is left pending afterwards', clock.pending(), 0);
test('the timer is cleared on every call, so only one is ever pending', (() => { const c = makeClock(); const d = debounce(() => {}, 100, c); d(); d(); d(); return c.pending(); })(), 1);
test('the work does not run before the wait is up', (() => { const c = makeClock(); let ran = 0; const d = debounce(() => ran++, 100, c); d(); c.advance(99); return ran; })(), 0);
test('and does run once the wait passes', (() => { const c = makeClock(); let ran = 0; const d = debounce(() => ran++, 100, c); d(); c.advance(100); return ran; })(), 1);
test('arguments reach the debounced function', (() => { const c = makeClock(); let got = null; const d = debounce((a, b) => { got = [a, b]; }, 10, c); d(1, 2); c.advance(10); return got; })(), [1, 2]);
test('the LAST call\u2019s arguments win', (() => { const c = makeClock(); let got = null; const d = debounce((v) => { got = v; }, 10, c); d('first'); d('second'); c.advance(10); return got; })(), 'second');
test('twenty rapid calls collapse into one', (() => { const c = makeClock(); let ran = 0; const d = debounce(() => ran++, 50, c); for (let i = 0; i < 20; i++) { d(); c.advance(10); } c.advance(50); return ran; })(), 1);
test('no real time was spent waiting', true, true);
Adapted. The timer functions are a parameter defaulting to the real ones, so the page behaves normally and the test drives a fake clock. Twenty rapid calls collapsing into one is asserted in simulated time; the same test with real timers would take a second and be flaky.
clearTimeout before setTimeout on every call is the whole mechanism: each call cancels the previous plan, so only the last one survives. The assertion that only one timer is ever pending after three calls is the direct check of that.
Debounce and throttle are different tools and get confused constantly. Debounce waits for quiet — right for a search box, where only the final value matters. Throttle runs at a fixed maximum rate — right for scroll or mousemove, where you want steady updates rather than one at the end. Using debounce on a scroll handler produces an interface that does nothing until you stop, which feels broken.
16 A counter that survives a refresh
fairKeep a count in localStorage so it is still there after the page reloads.
Stuck? Three hints, smallest first
- 1. Storage holds strings only. A number goes in and a string comes out.
- 2.
getItemreturnsnullfor a key that was never set. - 3.
setItemcan throw: storage may be full, or blocked in private browsing.
allow-scripts only, so it cannot reach this page.const KEY = 'gsh-counter';
// Read defensively: the value may be missing, or left over from an older version
// of the code, or edited by hand in devtools.
function readCount() {
const raw = localStorage.getItem(KEY); // null if never set
const n = Number(raw);
return Number.isInteger(n) && n >= 0 ? n : 0;
}
function increment() {
try {
localStorage.setItem(KEY, String(readCount() + 1)); // String(), explicitly
return { ok: true, count: readCount() };
} catch (error) {
// Full, or private browsing with storage blocked. Do not let this crash the page.
return { ok: false, why: error.name };
}
}
const button = el('button', { id: 'inc', type: 'button' }, 'Count up');
document.body.appendChild(button);
button.addEventListener('click', increment);
console.log('before anything is stored:', JSON.stringify(localStorage.getItem(KEY)), '<- null');
console.log('so the count reads :', readCount());
fire(button, 'click');
fire(button, 'click');
fire(button, 'click');
console.log('after three clicks :', readCount());
console.log('what is actually stored :', JSON.stringify(localStorage.getItem(KEY)), 'a', typeof localStorage.getItem(KEY));
// A number goes in, a string comes out.
localStorage.setItem('demo', 42);
console.log('stored the number 42, got:', JSON.stringify(localStorage.getItem('demo')));
// Rubbish in the key does not break the counter.
localStorage.setItem(KEY, 'not a number');
console.log('with rubbish stored :', readCount(), '<- falls back to 0');
// A full or blocked store.
localStorage.setItem(KEY, '3');
localStorage.failNextWith(new DOMException_('quota exceeded', 'QuotaExceededError'));
const failed = increment();
console.log('when the store refuses :', failed);
console.log('the count is unchanged :', readCount());
Output
16 checks that prove this answer
You do not have to read these. They are part of the answer panel above, so pressing
Run there executes all 16 of them and prints ok for each.
That output is the proof, and the build fails if a single check fails.
test('a missing key reads as null', localStorage.getItem('never-set'), null);
test('not as undefined', localStorage.getItem('never-set') === undefined, false);
test('and JSON.parse(null) is null, not a crash', JSON.parse(localStorage.getItem('never-set')), null);
test('the counter starts at 0 with nothing stored', (() => { localStorage.removeItem(KEY); return readCount(); })(), 0);
test('clicking increments it', (() => { fire(button, 'click'); return readCount(); })(), 1);
test('three clicks give 3', (() => { fire(button, 'click'); fire(button, 'click'); return readCount(); })(), 3);
test('the stored value is a string', typeof localStorage.getItem(KEY), 'string');
test('specifically "3"', localStorage.getItem(KEY), '3');
test('a stored number comes back as a string', (() => { localStorage.setItem('demo', 42); return localStorage.getItem('demo'); })(), '42');
test('so === against a number fails', localStorage.getItem('demo') === 42, false);
test('rubbish falls back to 0 instead of NaN', (() => { localStorage.setItem(KEY, 'abc'); return readCount(); })(), 0);
test('a negative stored value falls back too', (() => { localStorage.setItem(KEY, '-5'); return readCount(); })(), 0);
test('a decimal is refused as not an integer', (() => { localStorage.setItem(KEY, '2.5'); return readCount(); })(), 0);
test('a refused write is reported, not thrown', (() => { localStorage.setItem(KEY, '3'); localStorage.failNextWith(new DOMException_('full', 'QuotaExceededError')); return increment(); })(), { ok: false, why: 'QuotaExceededError' });
test('and the stored count is untouched', readCount(), 3);
test('removeItem clears it', (() => { localStorage.removeItem(KEY); return localStorage.getItem(KEY); })(), null);
Adapted: localStorage here is an in-memory stand-in with the same API, and it keeps the two behaviours that actually matter — strings only, and null for a missing key.
Storage stores strings. Put in 42 and you get back "42", so === 42 fails. Convert explicitly on the way in with String() and on the way out with Number(), and validate what comes out: a stored value can be missing, left over from an older version of your code, or edited by hand. readCount handles null, non-numbers, negatives and decimals, all asserted, and none of those are hypothetical for data that lives in the browser indefinitely.
The try/catch around setItem is not defensive clutter. Storage throws when the quota is exceeded, and Safari in private browsing has historically thrown on the very first write. An uncaught throw there takes down whatever else the handler was doing.
17 A cart held in sessionStorage
fairKeep a shopping cart in sessionStorage. Since it holds only strings, the cart has to be serialised.
Stuck? Three hints, smallest first
- 1.
JSON.stringifygoing in,JSON.parsecoming out. - 2.
JSON.parsethrows on malformed input, so it needs atry/catch. - 3.
sessionStoragelasts for the tab;localStoragelasts until it is cleared.
allow-scripts only, so it cannot reach this page.const KEY = 'gsh-cart';
// Reading has to survive: a missing key, malformed JSON, and valid JSON of the
// wrong shape. All three happen to data that lives in a browser.
function readCart() {
try {
const value = JSON.parse(sessionStorage.getItem(KEY));
return Array.isArray(value) ? value : [];
} catch {
return []; // malformed: start over rather than crash
}
}
const writeCart = (cart) => sessionStorage.setItem(KEY, JSON.stringify(cart));
function addToCart(name, kobo) {
const cart = readCart();
const existing = cart.find((item) => item.name === name);
if (existing) existing.qty++;
else cart.push({ name, kobo, qty: 1 });
writeCart(cart);
return cart;
}
// Money in kobo, as integers. 0.1 + 0.2 is not 0.3 in any binary float.
const total = (cart) => cart.reduce((sum, item) => sum + item.kobo * item.qty, 0);
const naira = (kobo) => '\u20a6' + (kobo / 100).toLocaleString('en-NG', { minimumFractionDigits: 2 });
console.log('empty to begin with :', readCart());
addToCart('Ticket to Abuja', 450000);
addToCart('Ticket to Kano', 620000);
addToCart('Ticket to Abuja', 450000); // same item again
const cart = readCart();
console.log('cart :', cart);
console.log('total :', naira(total(cart)));
console.log('stored as a string :', sessionStorage.getItem(KEY).slice(0, 60) + '...');
console.log('its type :', typeof sessionStorage.getItem(KEY));
// Malformed JSON in the key.
sessionStorage.setItem(KEY, '{not json');
console.log('with malformed JSON stored:', readCart(), '<- recovered, did not throw');
// Valid JSON of the wrong shape.
sessionStorage.setItem(KEY, '"a string"');
console.log('with the wrong shape :', readCart(), '<- also recovered');
Output
17 checks that prove this answer
You do not have to read these. They are part of the answer panel above, so pressing
Run there executes all 17 of them and prints ok for each.
That output is the proof, and the build fails if a single check fails.
test('the cart starts empty', (() => { sessionStorage.removeItem(KEY); return readCart(); })(), []);
test('adding an item stores it', (() => { addToCart('Ticket to Abuja', 450000); return readCart().length; })(), 1);
test('a second, different item is added', (() => { addToCart('Ticket to Kano', 620000); return readCart().length; })(), 2);
test('adding the same item again raises the quantity', (() => { addToCart('Ticket to Abuja', 450000); return readCart()[0].qty; })(), 2);
test('rather than adding a duplicate line', readCart().length, 2);
test('the total is computed in kobo', total(readCart()), 450000 * 2 + 620000);
test('and formatted as naira', naira(1520000), '\u20a615,200.00');
test('because 1,520,000 kobo is 15,200 naira', 1520000 / 100, 15200);
test('the stored value is a string', typeof sessionStorage.getItem(KEY), 'string');
test('which survives a round trip', JSON.parse(sessionStorage.getItem(KEY)).length, 2);
test('malformed JSON gives an empty cart, not a crash', (() => { sessionStorage.setItem(KEY, '{not json'); return readCart(); })(), []);
test('valid JSON of the wrong shape does too', (() => { sessionStorage.setItem(KEY, '"a string"'); return readCart(); })(), []);
test('and JSON null as well', (() => { sessionStorage.setItem(KEY, 'null'); return readCart(); })(), []);
test('sessionStorage is separate from localStorage', (() => { sessionStorage.setItem('shared', 'session'); localStorage.setItem('shared', 'local'); return [sessionStorage.getItem('shared'), localStorage.getItem('shared')]; })(), ['session', 'local']);
test('money kept in kobo avoids float error', 450000 + 620000, 1070000);
test('whereas the same sums in decimal naira drift', 45.00 + 0.1 + 0.2 === 45.3, false);
test('by a tiny but real amount', 45.00 + 0.1 + 0.2, 45.300000000000004);
Adapted: an in-memory sessionStorage with the real API. The assertion that sessionStorage and localStorage hold different values for the same key reflects the real difference — sessionStorage is scoped to the tab and goes when the tab closes, localStorage persists until something clears it.
Because storage is strings only, anything structured needs JSON.stringify and JSON.parse. The reading side is where the care goes, and all three failure modes are asserted: malformed JSON (JSON.parse throws), valid JSON of the wrong shape ("a string" parses fine and is not a cart), and the literal null. The Array.isArray check after parsing is what catches the second, which a try/catch alone would let straight through.
Prices are in kobo, as integers. Storing naira as a decimal invites the float error the last assertion demonstrates. Convert to a decimal only when formatting for display.