Courses Learn Blog Contact
Get Started →
Lesson 9

Day 9: Working With Real Data

Search, debounce, sort, page and normalise a real API response, and build the four states an honest interface needs.

Webbo3 Level 2: JavaScript Development 19 min read Free

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.

JSapp.js
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

JSapp.js
const names = ['ade', 'Zainab', 'Chidinma', 'bola'];

console.log([...names].sort());                       // capitals sort first
console.log([...names].sort((a, b) => a.localeCompare(b)));   // human order
Console

The 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.

JSapp.js
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));
Console

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.

JSapp.js
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);
Console

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

JSapp.js
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));
Console
  • Math.ceil for the page count. Math.round leaves the last three items with nowhere to go.
  • slice does not mutate and never throws past the end: it just returns fewer items, or none.
  • Reset page to 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.

JSapp.js
// 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);
Console

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.

IfShowNever show
The request is in flightA skeleton or spinner, and disable the buttonA blank screen
It threwWhat 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 clearAn empty area with no explanation
It succeeded with itemsThe listThe spinner, still turning
JSrender.js
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);
}
Result in the browser

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.

  1. Fetch once on load into one array.
  2. A search box, debounced at around 300ms, filtering locally.
  3. A sort control with at least two options, one of them text using localeCompare.
  4. Paging at ten per page, with the page reset to 1 whenever the search changes.
  5. All four states: loading skeleton, error with retry, empty with the search term quoted back, and the list itself.
  6. Normalise the API response into your own shape in one map before 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.

Learn this with a real instructor

Free lessons take you a long way on your own. The full programmes add live classes, assignments, Skill Points and a certificate — built for African students.