JAVASCRIPT / PROJECTS AND PROFESSIONAL CRAFT
Project: a data dashboard from a public JSON API
Build a dashboard that fetches JSON from a public API, reduces rows to a few numbers, and renders loading, error and ready states from one state value.
What you will learn
- Check res.ok before res.json(): fetch rejects only when there is no HTTP reply
- Reduce raw API rows into the few numbers the panels need, before touching the DOM
- Render loading, empty, error and ready branches from a single state value
- Discard out-of-order responses with a request ticket or AbortController
Understanding Project: a data dashboard from a public JSON API
A dashboard is three stages that should not be mixed: transport (ask the endpoint for bytes and check the HTTP status), transform (fold the raw records into the handful of numbers the panels display), and render (turn one state value into text or DOM). Only the first stage is slow and unreliable, so if the other two are ordinary synchronous functions over plain data you can paste a saved response into them and see the finished dashboard with no network at all. The API's field names are also not your field names; normalising at the boundary means that the day the endpoint renames a field you edit one function instead of hunting through rendering code.
A network read has four outcomes a user can tell apart - still pending, came back empty, failed, and ready - so the dashboard has to be able to draw all four, because a blank panel is indistinguishable from a broken one. Keeping status and data in a single state object and re-rendering the whole panel after each transition means the screen can never be half old numbers and half new ones. The status check matters more than beginners expect: fetch resolves happily for 404 and 500 and rejects only when no HTTP reply arrived at all, so without an explicit throw on !res.ok a JSON error page walks straight into your reducer.
Then there is time. Await orders the statements inside one call, but two calls started by two clicks race each other, and the panel keeps whichever finishes last - which may be the request the user already abandoned. A counter compared just before the write, or an AbortController that cancels the previous request (the aborted fetch then rejects with an AbortError you filter out of the error branch), lets the user's selection rather than network latency decide what is on screen.
// Transport, transform and render kept apart. fetch is a parameter, so the same
// loader runs against a real endpoint or against the fixed stub below.
const payload = {
results: [
{ country: 'Portugal', city: 'Lisbon', pm25: 18.4 },
{ country: 'Portugal', city: 'Porto', pm25: 12 },
{ country: 'Spain', city: 'Madrid', pm25: 24.6 },
{ country: 'Spain', city: 'Seville', pm25: 21.4 }
]
};
const stubFetch = () => Promise.resolve({
ok: true,
status: 200,
json: () => Promise.resolve(payload)
});
async function loadRows(fetchImpl, url) {
const res = await fetchImpl(url);
if (!res.ok) throw new Error('HTTP ' + res.status);
const body = await res.json();
return body.results;
}
function summarise(rows) {
const byCountry = new Map();
for (const row of rows) {
if (!Number.isFinite(row.pm25)) continue;
const acc = byCountry.get(row.country) || { stations: 0, total: 0 };
acc.stations += 1;
acc.total += row.pm25;
byCountry.set(row.country, acc);
}
return [...byCountry].map(([country, acc]) => ({
country,
stations: acc.stations,
mean: acc.total / acc.stations
}));
}
function render(state) {
if (state.status === 'loading') return 'Loading air quality...';
if (state.status === 'error') return 'Could not load data: ' + state.error;
if (state.data.length === 0) return 'No stations reported.';
return state.data
.map(r => `${r.country}: ${r.stations} stations, mean PM2.5 ${r.mean.toFixed(1)}`)
.join('\n');
}
async function main() {
let state = { status: 'loading' };
console.log(render(state));
try {
const rows = await loadRows(stubFetch, '/air?limit=4');
state = { status: 'ready', data: summarise(rows) };
} catch (err) {
state = { status: 'error', error: err.message };
}
console.log(render(state));
}
main();The unpredictable part is only the fetch; everything after it is a pure transform plus a render function over one state value that must include loading and error versions.
Worked examples
A 404 is a successful fetch
Shows why the status check, not the promise, decides whether you have data.
const respond = (status, body) => Promise.resolve({
ok: status >= 200 && status < 300,
status,
json: () => Promise.resolve(body)
});
async function readJson(responsePromise) {
const res = await responsePromise;
if (!res.ok) throw new Error('request failed with ' + res.status);
return res.json();
}
async function run() {
const cases = [respond(200, { total: 3 }), respond(404, { message: 'no such dataset' })];
for (const c of cases) {
try {
const data = await readJson(c);
console.log('ok, total =', data.total);
} catch (err) {
console.log('caught:', err.message);
}
}
}
run();Example explained
Line 1respond() supplies the only parts of a Response this code touches: ok, status and json().
Line 2The 404 case reaches readJson through a resolved promise, so only the !res.ok test can stop it.
Line 3A real fetch rejects only when no reply arrived (offline, DNS failure, CORS block), never for a status code.
Line 4Drop the throw and body.message flows into the reducer, producing NaN panels instead of a message.
Two clicks, two responses, wrong winner
A request ticket keeps a slow earlier response from overwriting the newer one.
const delay = (ms, value) => new Promise(resolve => setTimeout(() => resolve(value), ms));
let latestTicket = 0;
let panel = 'empty';
async function selectRange(range, ms) {
const ticket = ++latestTicket;
const data = await delay(ms, range + ' totals');
if (ticket !== latestTicket) {
console.log('ignored stale response:', range);
return;
}
panel = data;
console.log('rendered:', panel);
}
selectRange('year', 60);
selectRange('week', 10);
setTimeout(() => console.log('panel still shows:', panel), 120);Example explained
Line 1++latestTicket runs synchronously, so year takes ticket 1 and week takes ticket 2 before either request finishes.
Line 2The 10 ms week response resolves first and its ticket still equals latestTicket, so it writes to the panel.
Line 3The 60 ms year response resolves second with ticket 1, which no longer matches, so its write is skipped.
Line 4Without that comparison the panel would end on year totals while the dropdown says week.
One null field ruins a KPI
Filtering with Number.isFinite before reducing keeps missing values from poisoning an average.
const rows = [
{ city: 'Oslo', temp: -3.5 },
{ city: 'Rome', temp: null },
{ city: 'Cairo', temp: 31.5 },
{ city: 'Lima', temp: 'n/a' },
{ city: 'Perth', temp: 22 }
];
const naive = rows.reduce((sum, r) => sum + r.temp, 0) / rows.length;
const usable = rows.filter(r => Number.isFinite(r.temp));
const mean = usable.reduce((sum, r) => sum + r.temp, 0) / usable.length;
console.log('naive mean:', naive);
console.log('usable readings:', usable.length, 'of', rows.length);
console.log('mean temp:', mean.toFixed(1));Example explained
Line 1null adds as 0, so Rome silently drags the naive sum down without raising anything.
Line 2Adding the string 'n/a' switches + to concatenation, and dividing '28n/a22' by 5 gives NaN.
Line 3Number.isFinite does no coercion, so it rejects null, 'n/a' and NaN in one test; the global isFinite(null) is true.
Line 4Dividing by usable.length keeps the average honest about how many stations actually reported.
Important notes
In a browser the response only reaches your code if the API sends Access-Control-Allow-Origin; a CORS block makes fetch reject with a TypeError and no status, so it lands in your catch with a message unrelated to the data.
Never put an API key in front-end fetch code, since anyone can read it in the Network tab; practise on key-free public endpoints or proxy through a small server you control.
Common mistakes
Calling res.json() without checking res.ok: the 404 error body parses fine, body.results is undefined, and the failure surfaces as 'rows is not iterable' in the console instead of a message the user can read.
Summing a field the API sometimes returns as null or as a string: one record turns the KPI into NaN or a concatenated string, and the dashboard shows a confidently wrong number with no warning.
Fetching on every keystroke or filter click with no cancellation: responses arrive out of order so the panel can show data for a filter the user already left, and the endpoint's rate limit runs out fast.
Try it yourself
Change, predict, then run
Point the loader at a key-free public endpoint that returns an array, such as https://api.github.com/repos/nodejs/node/issues?per_page=50, and render three numbers: how many items came back, how many carry at least one label, and the newest item's title. Then set DevTools to Offline and reload to confirm your error branch appears instead of an empty panel.
Open the JavaScript workspaceCheck your understanding
A dashboard reloads on every dropdown change. The user picks India (that query takes 800 ms), then 120 ms later picks Chile (that query takes 120 ms). Both requests succeed and each awaits its own fetch before writing to the panel. One second later, what is on screen?
- Chile's numbers, since that was the last selection the user made
- India's numbers, since its slower response resolves last and does the final write
- Chile's numbers, since starting a second fetch aborts the first
- An error, since two requests to the same endpoint cannot overlap
Show answer
Await only sequences the steps inside one call; the two calls run concurrently, and the panel keeps whichever write happens last, which is India's at 800 ms. Chile looks right because it is what the user expects, but nothing in fetch or await cancels the earlier request - you get that behaviour only after adding a ticket comparison before the write or an AbortController on the previous request.