JavaScript: Events, 18–21
Listening, bubbling and delegation.
Exercises 18 to 21 of 21
18 Fetch with real error handling
hardFetch data, handle a network failure and a bad status differently, and show the result. The trap is what fetch treats as an error.
Stuck? Three hints, smallest first
- 1.
fetchdoes not reject on 404 or 500. It resolves, andresponse.okis false. - 2. It rejects only when the request could not be made at all — no connection, DNS failure, CORS.
- 3. So you need a
try/catchand anif (!response.ok). One is not enough.
allow-scripts only, so it cannot reach this page.const fetch = makeFetch({
'/routes': { status: 200, body: '[{"from":"Ojota","to":"Abuja"},{"from":"Ojota","to":"Kano"}]' },
'/missing': { status: 404, body: 'not found' },
'/broken': { status: 500, body: 'server error' },
'/malformed': { status: 200, body: '{not json' },
'/offline': 'network-error',
});
const out = el('pre', { id: 'out' });
document.body.appendChild(out);
/* Three distinct failures, three distinct messages:
the request never happened -> fetch REJECTS
the server answered with an error -> fetch RESOLVES, response.ok is false
the body was not what we expected -> response.json() throws */
async function load(url) {
try {
const response = await fetch(url);
if (!response.ok) {
return { state: 'http', status: response.status, message: `The server answered ${response.status} ${response.statusText}.` };
}
const data = await response.json(); // throws on malformed JSON
return { state: 'ok', count: data.length, data };
} catch (error) {
if (error instanceof SyntaxError) {
return { state: 'malformed', message: 'The server answered 200, but the body was not valid JSON.' };
}
return { state: 'offline', message: 'Could not reach the server. Check your connection.' };
}
}
for (const url of ['/routes', '/missing', '/broken', '/malformed', '/offline']) {
const result = await load(url);
console.log(url.padEnd(11), '->', result.state.padEnd(10), result.message ?? `${result.count} route(s)`);
}
const ok = await load('/routes');
const notFound = await load('/missing');
const serverError = await load('/broken');
const malformed = await load('/malformed');
const offline = await load('/offline');
// The heart of it: a 404 RESOLVES.
const raw404 = await fetch('/missing');
console.log('a 404 response: ok =', raw404.ok, ', status =', raw404.status, '<- it resolved');
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('a good response loads', ok.state, 'ok');
test('with the parsed data', ok.count, 2);
test('and the data is usable', ok.data[0].to, 'Abuja');
test('a 404 does NOT reject', raw404.status, 404);
test('it resolves with ok false', raw404.ok, false);
test('so it is reported as an http error', notFound.state, 'http');
test('with the status in the message', notFound.message, 'The server answered 404 Not Found.');
test('a 500 is handled the same way', serverError.state, 'http');
test('with its own status', serverError.status, 500);
test('a network failure DOES reject', offline.state, 'offline');
test('and gets a different message', offline.message.includes('connection'), true);
test('the two failures are told apart', notFound.state === offline.state, false);
test('malformed JSON on a 200 is its own case', malformed.state, 'malformed');
test('and is not mistaken for a network problem', malformed.message.includes('connection'), false);
test('a 200 response is ok', (await fetch('/routes')).ok, true);
test('an unknown route answers 404 rather than throwing', (await fetch('/nowhere')).status, 404);
test('every request was recorded', fetch.calls.length > 5, true);
Adapted: fetch here answers from a local routing table, because this course loads nothing over the network. The demo uses the same technique so it works offline too.
The thing to take away: fetch rejects only when the request could not be made — no connection, DNS failure, blocked by CORS. A 404 or a 500 is a successful HTTP exchange as far as fetch is concerned, so the promise resolves and response.ok is false. The assertions check raw404.status is 404 on a resolved response, which is the whole point.
So a try/catch alone is not error handling. Without if (!response.ok), a 500 flows into response.json(), which throws a SyntaxError on the HTML error page the server returned, and your catch block reports "check your connection" for a working connection. Three failure modes, three messages, and the assertion that two of them differ is what stops them collapsing into one unhelpful line.
19 Read the current location
fairUse the Geolocation API to report where the user is. Handle refusal, since it is the most likely outcome.
Stuck? Three hints, smallest first
- 1.
getCurrentPosition(success, error, options)is callback-based, not a promise. - 2. The error object has a numeric
code: 1 denied, 2 unavailable, 3 timed out. - 3. It needs a secure context, and permission is asked once. Refusal is permanent until the user changes it.
allow-scripts only, so it cannot reach this page.const out = el('pre', { id: 'out' }, 'not asked yet');
document.body.appendChild(out);
const describe = (position) => {
const { latitude, longitude, accuracy } = position.coords;
return `latitude ${latitude.toFixed(4)}, longitude ${longitude.toFixed(4)}, accurate to about ${Math.round(accuracy)}m`;
};
// The codes are numbers, not names. Compare against the named constants on the
// geolocation object rather than writing 1, 2, 3 in your own code.
const explain = (error, geo) => {
if (error.code === geo.PERMISSION_DENIED) return 'You refused permission. Nothing more can be done from here.';
if (error.code === geo.POSITION_UNAVAILABLE) return 'Your position is not available right now.';
if (error.code === geo.TIMEOUT) return 'It took too long. Try again.';
return `Something went wrong: ${error.message}`;
};
// Callback-based, so wrapping it in a promise is usually the first thing you do.
const locate = (geo, options = {}) =>
new Promise((resolve, reject) => geo.getCurrentPosition(resolve, reject, options));
const granted = makeGeolocation({ kind: 'success', latitude: 6.5244, longitude: 3.3792, accuracy: 18 });
const denied = makeGeolocation({ kind: 'error', code: 1, message: 'User denied Geolocation' });
const unavailable = makeGeolocation({ kind: 'error', code: 2, message: 'Position unavailable' });
const timedOut = makeGeolocation({ kind: 'error', code: 3, message: 'Timeout expired' });
let shown = '';
granted.getCurrentPosition((p) => { shown = describe(p); out.textContent = shown; });
console.log('granted :', shown);
for (const [name, geo] of [['denied', denied], ['unavailable', unavailable], ['timed out', timedOut]]) {
let message = '';
geo.getCurrentPosition(() => {}, (e) => { message = explain(e, geo); });
console.log(name.padEnd(12), ':', message);
}
// Omitting the error callback: the real API just does nothing.
let ranAnyway = false;
denied.getCurrentPosition(() => { ranAnyway = true; });
console.log('no error callback, success handler ran?', ranAnyway, '<- silently nothing');
// The promise wrapper.
const position = await locate(granted);
console.log('promise-wrapped:', position.coords.latitude);
let caught = null;
try { await locate(denied); } catch (e) { caught = e.code; }
console.log('rejection code :', caught);
Output
15 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 15 of them and prints ok for each.
That output is the proof, and the build fails if a single check fails.
test('a granted request reports coordinates', shown, 'latitude 6.5244, longitude 3.3792, accurate to about 18m');
test('the latitude is a number', typeof position.coords.latitude, 'number');
test('accuracy is reported in metres', position.coords.accuracy, 18);
test('a refusal is code 1', denied.PERMISSION_DENIED, 1);
test('and gets its own message', (() => { let m = ''; denied.getCurrentPosition(() => {}, (e) => { m = explain(e, denied); }); return m; })(), 'You refused permission. Nothing more can be done from here.');
test('unavailable is code 2', unavailable.POSITION_UNAVAILABLE, 2);
test('with a different message', (() => { let m = ''; unavailable.getCurrentPosition(() => {}, (e) => { m = explain(e, unavailable); }); return m; })(), 'Your position is not available right now.');
test('a timeout is code 3', timedOut.TIMEOUT, 3);
test('and is told apart from a refusal', (() => { let m = ''; timedOut.getCurrentPosition(() => {}, (e) => { m = explain(e, timedOut); }); return m; })(), 'It took too long. Try again.');
test('the three codes are distinct', new Set([1, 2, 3]).size, 3);
test('the success callback does not run on failure', ranAnyway, false);
test('omitting the error callback throws nothing', typeof ranAnyway, 'boolean');
test('the promise wrapper resolves on success', position.coords.longitude, 3.3792);
test('and rejects with the error code on failure', caught, 1);
test('the error is not an Error instance, just an object with a code', (() => { let e = null; denied.getCurrentPosition(() => {}, (err) => { e = err; }); return e instanceof Error; })(), false);
Adapted: a stand-in geolocation, controllable so all four outcomes are exercised rather than only the happy one. The demo's third button calls the real API with a 1ms timeout, so you can see a genuine browser response.
Geolocation is callback-based, which is why locate() wraps it in a promise — worth doing once at the boundary so the rest of your code can await.
The error is not an Error; it is a plain object with a numeric code, and the last assertion confirms instanceof Error is false — so error.message exists but error.stack does not, and nothing about it will look familiar in a catch block. Compare against PERMISSION_DENIED, POSITION_UNAVAILABLE and TIMEOUT rather than the bare numbers.
Two practical notes. Permission is asked once, and a refusal is permanent until the user changes it in browser settings, so there is no point re-asking — say what happened and move on. And the API needs a secure context: it works on https and on localhost, and is unavailable on plain http.
20 Copy a text box to the clipboard
fairCopy an input's contents to the clipboard when a button is pressed, and tell the user whether it worked.
Stuck? Three hints, smallest first
- 1.
navigator.clipboard.writeText(text)returns a promise, so it can fail. - 2. It only works from a user gesture and a secure context. Calling it on a timer is rejected.
- 3. Always confirm visibly. A silent copy is indistinguishable from a broken button.
allow-scripts only, so it cannot reach this page.const box = el('input', { id: 'box', value: 'LAG-441-XA' });
const status = el('span', { id: 'status', role: 'status' });
const copy = el('button', { id: 'copy', type: 'button' }, 'Copy');
document.body.appendChild(el('p', {}, [box, copy, status]));
// Passed in, so both the allowed and the refused case can be exercised.
function wireCopy(clipboard) {
return async () => {
const text = box.value;
if (text.trim() === '') { status.textContent = 'Nothing to copy.'; return 'empty'; }
try {
await clipboard.writeText(text);
status.textContent = 'Copied.'; // always confirm
return 'copied';
} catch (error) {
// Refused, insecure context, or no user gesture. Give a way forward.
status.textContent = `Could not copy (${error.name}). Select it and press Ctrl+C.`;
return 'failed';
}
};
}
const allowed = makeClipboard();
const refused = makeClipboard({ kind: 'denied' });
const copyAllowed = wireCopy(allowed);
const copyRefused = wireCopy(refused);
console.log('before:', JSON.stringify(status.textContent));
console.log('result:', await copyAllowed());
console.log('status:', status.textContent, '| clipboard now holds:', allowed.written);
console.log('refused:', await copyRefused());
console.log('status :', status.textContent);
box.value = ' ';
console.log('empty :', await copyAllowed(), '|', status.textContent);
box.value = 'LAG-441-XA';
Output
15 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 15 of them and prints ok for each.
That output is the proof, and the build fails if a single check fails.
test('a successful copy reports success', await copyAllowed(), 'copied');
test('and the text reached the clipboard', allowed.written.at(-1), 'LAG-441-XA');
test('and the user is told', status.textContent, 'Copied.');
test('reading it back gives the same text', await allowed.readText(), 'LAG-441-XA');
test('a refusal does not throw at the caller', await copyRefused(), 'failed');
test('the message names the error', status.textContent.includes('NotAllowedError'), true);
test('and offers a manual route', status.textContent.includes('Ctrl+C'), true);
test('nothing was written on refusal', refused.written.length, 0);
test('an empty box copies nothing', (() => { box.value = ''; return true; })() && await copyAllowed() === 'empty', true);
test('and says so', status.textContent, 'Nothing to copy.');
test('a box of only spaces is also empty', (() => { box.value = ' '; return true; })() && await copyAllowed() === 'empty', true);
test('and neither attempt touched the clipboard: still just the demo copy and the one above', allowed.written.length, 2);
test('copying a changed value copies the new one', (() => { box.value = 'ABJ-772-KM'; return true; })() && (await copyAllowed(), allowed.written.at(-1)), 'ABJ-772-KM');
test('writeText returns a promise', typeof allowed.writeText('x').then, 'function');
test('the status element is announced', status.getAttribute('role'), 'status');
Adapted: a stand-in clipboard, so both the allowed and the refused path are asserted. The demo uses the real API, and if the sandboxed frame is not permitted to use it you will see the error path — which is the half worth seeing.
writeText returns a promise and it genuinely fails: it requires a secure context, and it requires being called during a real user gesture. Calling it from a setTimeout, or after an await that outlives the gesture, is rejected. So await it inside a try/catch, and do the copy first in the handler rather than after other asynchronous work.
Two interface points. Confirm visibly — a copy leaves no trace on screen, so without a message the button is indistinguishable from a broken one. And when it fails, say what to do instead: "select it and press Ctrl+C" is more use than "copy failed". role="status" on the message makes it announced without stealing focus.
21 Send a desktop notification, with permission
hardAsk for notification permission and then send one. Handle all three permission states, and do not ask at the wrong moment.
Stuck? Three hints, smallest first
- 1.
Notification.permissionis‘default’,‘granted’or‘denied’. - 2.
requestPermission()returns a promise. Once denied, asking again does nothing. - 3. Constructing a
Notificationwithout permission throws, so check first.
allow-scripts only, so it cannot reach this page.const out = el('pre', { id: 'out' });
document.body.appendChild(out);
/* All three states handled, and the order matters: check 'denied' BEFORE asking,
because asking again after a refusal is pointless and the answer is 'denied'
without any prompt. */
async function notify(Notification, title, body) {
if (Notification.permission === 'denied') {
return { state: 'denied', message: 'Permission was refused earlier. The browser will not ask again.' };
}
if (Notification.permission === 'default') {
const answer = await Notification.requestPermission();
if (answer !== 'granted') {
return { state: answer, message: `Not granted: "${answer}". Nothing sent.` };
}
}
try {
new Notification(title, { body }); // throws without permission
return { state: 'sent', message: 'Sent.' };
} catch (error) {
return { state: 'error', message: `Could not send: ${error.name}` };
}
}
const fresh = makeNotification('default', 'granted');
const willRefuse = makeNotification('default', 'denied');
const alreadyDenied = makeNotification('denied', 'granted');
const alreadyGranted = makeNotification('granted', 'granted');
console.log('a fresh browser, permission is:', fresh.permission);
const first = await notify(fresh, 'Bus departing', 'Ojota to Abuja, 14 seats left');
console.log('after asking and being allowed :', first, '| now:', fresh.permission);
console.log('what was shown :', fresh.shown);
const refusedNow = await notify(willRefuse, 'Bus departing', 'body');
console.log('asked and refused :', refusedNow);
const denied = await notify(alreadyDenied, 'Bus departing', 'body');
console.log('already denied :', denied);
console.log('re-asking after a refusal gives :', await alreadyDenied.requestPermission(), '<- no prompt');
const straight = await notify(alreadyGranted, 'Already allowed', 'no prompt needed');
console.log('already granted :', straight);
// Constructing one without permission throws.
const tooEarly = makeNotification('default', 'granted');
let threw = null;
try { new tooEarly('too early'); } catch (e) { threw = e.constructor.name; }
console.log('constructing before granting :', threw);
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('permission starts as default', makeNotification('default').permission, 'default');
test('asking and being allowed sends it', first.state, 'sent');
test('and the permission is now granted', fresh.permission, 'granted');
test('the notification carries its title', fresh.shown[0].title, 'Bus departing');
test('and its body', fresh.shown[0].body, 'Ojota to Abuja, 14 seats left');
test('exactly one was shown', fresh.shown.length, 1);
test('being refused when asked sends nothing', refusedNow.state, 'denied');
test('and says so', refusedNow.message.includes('Nothing sent'), true);
test('an already-denied browser is not asked again', denied.state, 'denied');
test('with a message explaining it is final', denied.message.includes('will not ask again'), true);
test('re-asking after a refusal returns denied without prompting', await alreadyDenied.requestPermission(), 'denied');
test('and it stays denied', alreadyDenied.permission, 'denied');
test('an already-granted browser sends straight away', straight.state, 'sent');
test('constructing without permission throws', threw, 'TypeError');
test('so the check has to come first', tooEarly.shown.length, 0);
test('the three states are the only ones', ['default', 'granted', 'denied'].includes(fresh.permission), true);
Adapted: a stand-in Notification, so all four paths — fresh and allowed, fresh and refused, already denied, already granted — are asserted. A sandboxed frame usually cannot show real notifications, so the demo will most likely take the refused path, which is the one worth seeing anyway.
Checking 'denied' before calling requestPermission() is the ordering that matters. A browser never re-prompts after a refusal: the promise resolves to 'denied' immediately with no dialog, so asking again just produces a silent no. The assertion confirms that. Tell the user it was refused rather than appearing to try.
Constructing a Notification without permission throws a TypeError, which the assertions check, so the permission test cannot be skipped.
Worth saying plainly: asking for notification permission the moment a page loads is the reason browsers now suppress these prompts, and Chrome and Firefox both hide repeat requests. Ask after the user does something that implies they want to be told — subscribing to a departure alert, say — not on arrival.