Webbo3 Level 2 · JavaScript Development · Day 9 of 20
Working With Real Data
Search, filter, sort and page through a real API response without the interface ever lying to the person using it.
By the end of today you can
- Fetch once and re-render many times from data you already hold
- Search and filter without firing a request on every keystroke
- Page through a long list
- Show loading, error and empty states that mean something
- Reshape an awkward API response into the shape your code wants
Yesterday you got data onto the page. Today you make it usable, which is a different job. Two hundred results with no search is not a feature, it is a wall. And every control you add is a chance to tell the user something untrue: an empty list that looks like a broken one, a spinner that never stops, a search that fires forty requests while somebody types their name.
1. One fetch, many renders
The single most important structural decision today: fetch once into an array, then filter and sort that array locally. Do not go back to the network every time the user types a letter.
let allStudents = []; // the truth, fetched once
let search = '';
let sortBy = 'name';
let page = 1;
async function load() {
allStudents = await apiGet('/api/students');
render();
}
function visible() {
return allStudents
.filter((s) => s.name.toLowerCase().includes(search.toLowerCase()))
.sort((a, b) => sortBy === 'name'
? a.name.localeCompare(b.name)
: b.score - a.score);
}
function render() { /* draw visible() */ }This is the same shape as your Day 5 project, and that is not a coincidence. One array of truth, a function that answers "what should be on screen right now", and a render that draws it. It scales from ten tasks to a paged API list without changing shape.
When to filter on the server instead
Local filtering is right while the whole list fits comfortably in memory: hundreds of records, not hundreds of thousands. Past that, you send the search to the API and let the database do it. The signal that you have crossed the line is the page getting slow to type in.
2. Sorting real data, and the trap in it
const names = ['ade', 'Zainab', 'Chidinma', 'bola'];
console.log([...names].sort()); // capitals sort first
console.log([...names].sort((a, b) => a.localeCompare(b))); // human orderThe default sort compares character codes, and every capital letter comes before every lowercase one, so Zainab lands before ade. localeCompare sorts the way a person expects, and handles accented characters properly too.
const students = [
{ name: 'Chidinma', score: 88, joined: '2026-03-01' },
{ name: 'Musa', score: 54, joined: '2026-01-15' },
{ name: 'Amaka', score: 88, joined: '2026-02-10' }
];
// score descending, then name as the tiebreak
const ranked = [...students].sort((a, b) => b.score - a.score || a.name.localeCompare(b.name));
console.log(ranked.map((s) => `${s.name} ${s.score}`));
// dates: compare as dates, not as text
const byDate = [...students].sort((a, b) => new Date(a.joined) - new Date(b.joined));
console.log(byDate.map((s) => s.joined));The || in that first comparator is a neat idiom worth stealing: when the scores are equal, b.score - a.score is 0, which is falsy, so the name comparison runs instead. That is how you write a tiebreak in one line.
3. Debouncing: stop firing on every keystroke
An input listener runs on every character. Somebody typing "Chidinma" fires it eight times. If each one hits the network, you have sent eight requests to get one answer, and they can come back out of order so the screen ends up showing the result for "Chidinm".
Debouncing waits until the typing stops before doing the work.
function debounce(fn, waitMs) {
let timer;
return (...args) => {
clearTimeout(timer); // cancel the previous plan
timer = setTimeout(() => fn(...args), waitMs);
};
}
let callCount = 0;
const search = debounce((term) => {
callCount += 1;
console.log(`search ran for "${term}" (call ${callCount})`);
}, 60);
// simulate somebody typing C-h-i-d quickly, then pausing
['C', 'Ch', 'Chi', 'Chid'].forEach((term, i) => setTimeout(() => search(term), i * 15));
setTimeout(() => console.log(`total searches actually run: ${callCount}`), 300);Four keystrokes, one search. That is the whole idea: clearTimeout cancels the plan made by the previous keystroke, so only the last one survives the pause.
Debounce or throttle
Debounce waits for a pause: right for search boxes and resize handlers. Throttle runs at most once every N milliseconds: right for scroll position and drag. If you want "when they stop", debounce. If you want "regularly while it happens", throttle.
4. Paging
const results = Array.from({ length: 23 }, (_, i) => `Item ${i + 1}`);
const perPage = 10;
function pageOf(items, page, size) {
const start = (page - 1) * size;
return items.slice(start, start + size);
}
const totalPages = Math.ceil(results.length / perPage);
console.log(`${results.length} results across ${totalPages} pages`);
console.log('page 1:', pageOf(results, 1, perPage).length, 'items');
console.log('page 3:', pageOf(results, 3, perPage));
console.log('page 9 (past the end):', pageOf(results, 9, perPage));Math.ceilfor the page count.Math.roundleaves the last three items with nowhere to go.slicedoes not mutate and never throws past the end: it just returns fewer items, or none.- Reset
pageto 1 whenever the search or filter changes, or the user searches and sees an empty page 3.
The paging bug everybody ships once
Filter, then page. Page, then filter, and you slice ten records out of the full list and then filter those ten, so page 1 shows two results and page 2 shows none. Order matters: narrow first, cut into pages second.
5. Reshaping an awkward response
APIs return the shape that suited their database, not the shape that suits your component. Normalise it once, at the edge, and let the rest of your code work with something sensible.
// what the API sends
const raw = {
data: {
items: [
{ user_id: 1, first_nm: 'Chidinma', last_nm: 'Okafor', crs: null, pts: '88' },
{ user_id: 2, first_nm: 'Musa', last_nm: 'Bello', crs: 'JS', pts: '54' }
]
}
};
// what your code actually wants
const students = raw.data.items.map((row) => ({
id: row.user_id,
name: `${row.first_nm} ${row.last_nm}`,
course: row.crs ?? 'Unassigned',
score: Number(row.pts)
}));
console.log(students);
console.log('average:', students.reduce((s, x) => s + x.score, 0) / students.length);Three real problems fixed in one map: the names were split, crs was null for a student with no course, and pts arrived as a string so any arithmetic on it would have concatenated instead of added. Doing this once at the boundary means no other file has to know the API is awkward.
6. The states, properly this time
Yesterday you met loading, error and empty. Today they become a rule you apply every time, because this is what separates an application that feels finished from one that feels broken.
| If | Show | Never show |
|---|---|---|
| The request is in flight | A skeleton or spinner, and disable the button | A blank screen |
| It threw | What went wrong in plain words, plus a Retry | [object Object] or a raw stack trace |
| It succeeded with zero items | "No results for \"xyz\"" and a way to clear | An empty area with no explanation |
| It succeeded with items | The list | The spinner, still turning |
function render() {
if (state.loading) return skeleton();
if (state.error) return errorBox(state.error);
if (state.items.length === 0) return emptyBox(state.search);
return list(state.items);
}The empty state is not an error state
A search that legitimately matched nothing has worked perfectly. Say so in those terms, name what was searched for so the user can see their own typo, and give them a way back. An empty list dressed up as a failure teaches people not to trust your app.
Do this now
Build a browser for a real public API. Countries, GitHub users or posts: your choice, as long as it returns at least fifty records.
- Fetch once on load into one array.
- A search box, debounced at around 300ms, filtering locally.
- A sort control with at least two options, one of them text using
localeCompare. - Paging at ten per page, with the page reset to 1 whenever the search changes.
- All four states: loading skeleton, error with retry, empty with the search term quoted back, and the list itself.
- Normalise the API response into your own shape in one
mapbefore anything else touches it.
Prove the states rather than assuming them
Break the URL on purpose and confirm the error state appears. Search for zzzz and confirm the empty state appears. Throttle to Slow 3G in the Network tab and watch your own loading state for four seconds. If you have not seen all three with your own eyes, you have not built them.
Checkpoint
Q1. Why filter before paging rather than after?
Paging first slices ten records out of the unfiltered list and then filters those ten, so pages come back nearly empty. Narrow the list first, then cut it into pages.
Q2. What does `clearTimeout` actually do inside a debounce?
It cancels the plan made by the previous keystroke. Every new character throws away the pending call and schedules a fresh one, so only the last one in a burst ever runs.
Q3. Why does `["ade", "Zainab"].sort()` put Zainab first?
The default sort compares character codes, and every capital letter has a lower code than every lowercase one. Use localeCompare for anything a person will read.
Q4. Zero results came back. Is that an error?
No. The request succeeded and the honest answer was "nothing matches". Show an empty state naming what was searched for, not an error.
Tick before you move on
- ☐ I fetch once and filter locally, not on every keystroke
- ☐ My search is debounced and I proved it by counting the calls
- ☐ Search resets the page number to 1
- ☐ I have seen all four states on my own screen, including on Slow 3G
- ☐ The API response is normalised in one place before anything else uses it
Quick recap
Fetch once into one array; filter, sort and page that array · localeCompare for text, and || for a tiebreak comparator · Debounce waits for a pause; throttle runs on a timer · Filter first, page second, and reset the page when the filter changes · Loading, error, empty and list are four different screens, all of them yours
Tomorrow, Day 10: your first group project, and the Git workflow that lets five people work on one codebase without destroying each other's work.