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 12 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
  • Sort text the way people expect with localeCompare, including a tiebreak
  • Search without firing a request on every keystroke, using a debounce
  • Page through a long list, and filter before you page
  • Reshape an awkward API response into the shape your code wants
  • Show loading, error, empty and list states that each mean something

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 broken, a spinner that never stops, a search that fires forty requests while somebody types their name.

1. One Fetch, Many Renders

Definition

Fetch once, render many means you fetch the data into one array when the page loads, then search, filter and sort that array locally every time the user changes something. You do not go back to the network for every letter typed.

Real-Life Example

A librarian photocopies the class list once in the morning. When a teacher asks "who is from Lagos?", the librarian looks at the copy on the desk. They do not walk to the records office for every question.

How It Works

  1. Load: fetch into one array, once.
  2. Derive: a function works out which items to show from the search, sort and page.
  3. Render: draw what that function returns, every time something changes.

This is your Day 5 project's shape again, now with the array coming from an API.

What this code doesWe want to fetch the students once, then filter them on every keystroke without another request. The console counts how many fetches really happened.
index.html
1
2
<input id="search" placeholder="Search students">
<ul id="list"></ul>
app.js
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
// The truth, fetched once
let allStudents = [];
let search = '';
let fetches = 0;

// Which students match the search right now?
function visible() {
  return allStudents.filter((student) =>
    student.name.toLowerCase().includes(search.toLowerCase()));
}

// Draw whatever visible() says
function render() {
  document.querySelector('#list').innerHTML = visible()
    .map((student) => `<li>${student.name} (${student.city})</li>`)
    .join('');
}

// Load once
async function load() {
  fetches = fetches + 1;
  const response = await fetch('https://api.webbo3.test/students');
  allStudents = await response.json();
  render();
}

// Every keystroke: change the search, redraw, no fetch
document.querySelector('#search').addEventListener('input', (event) => {
  search = event.target.value;
  render();
  console.log(`typed "${search}": ${visible().length} shown, fetches so far: ${fetches}`);
});

load();
BROWSERindex.html · after typing "mus"
CONSOLE   BROWSER DEVTOOLS
typed "m": 4 shown, fetches so far: 1
typed "mu": 2 shown, fetches so far: 1
typed "mus": 2 shown, fetches so far: 1
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 becoming slow to type in.

Picture This

Picture the photocopied class list on the librarian's desk. Every question is answered from the copy; the records office is visited once.

2. Sorting Real Data

Definition

a.localeCompare(b) compares two pieces of text the way a person would, and gives back a negative number, zero or a positive number, exactly what sort needs.

Real-Life Example

Arranging a class list. A person puts "ade" and "Ade" next to each other. A computer comparing raw character codes puts every capital letter before every small one, so "Zainab" lands before "ade".

Types of Sort You Will Need

  1. Text, in the order a person expects.
  2. Numbers, with a second rule for ties.
  3. Dates, compared as dates rather than as text.

Type 1: Text

What this code doesWe want to compare the default sort with localeCompare on names that mix capital and small letters.
app.js
1
2
3
4
5
6
7
const names = ['ade', 'Zainab', 'Chidinma', 'bola'];

// Default sort: capitals first, because of character codes
console.log([...names].sort());

// localeCompare: the order a person expects
console.log([...names].sort((a, b) => a.localeCompare(b)));
OUTPUT   TERMINAL
➜ webbo3 node app.js
[ 'Chidinma', 'Zainab', 'ade', 'bola' ]
[ 'ade', 'bola', 'Chidinma', 'Zainab' ]

[...names] sorts a copy (Day 3), so the original list keeps its order.

Type 2: Numbers With a Tiebreak

When two scores are equal, b.score - a.score is 0, which is falsy, so || moves on to the name comparison. That is a tiebreak in one line.

What this code doesWe want a ranking by score, with students on the same score listed alphabetically.
app.js
1
2
3
4
5
6
7
8
9
10
11
const students = [
  { name: 'Chidinma', score: 88 },
  { name: 'Musa', score: 54 },
  { name: 'Amaka', score: 88 }
];

// Highest score first; if two scores are equal, sort those by name
const ranked = [...students].sort((a, b) =>
  b.score - a.score || a.name.localeCompare(b.name));

console.log(ranked.map((student) => `${student.name} ${student.score}`));
OUTPUT   TERMINAL
➜ webbo3 node app.js
[ 'Amaka 88', 'Chidinma 88', 'Musa 54' ]

Type 3: Dates

Subtracting two Date objects gives the milliseconds between them, which sort can use directly.

What this code doesWe want to order students by the date they joined, comparing real dates rather than text.
app.js
1
2
3
4
5
6
7
8
9
10
const students = [
  { name: 'Chidinma', joined: '2026-03-01' },
  { name: 'Musa', joined: '2026-01-15' },
  { name: 'Amaka', joined: '2026-02-10' }
];

// Turn the text into dates, then subtract: earliest first
const byDate = [...students].sort((a, b) => new Date(a.joined) - new Date(b.joined));

console.log(byDate.map((student) => `${student.joined} ${student.name}`));
OUTPUT   TERMINAL
➜ webbo3 node app.js
[ '2026-01-15 Musa', '2026-02-10 Amaka', '2026-03-01 Chidinma' ]
Picture This

Picture two people arranging the same register: one reads the names like a person, the other reads raw character codes. localeCompare makes the computer read like the person.

3. Debouncing: Stop Firing on Every Keystroke

Definition

Debouncing waits until someone stops typing for a moment, and only then does the work. Each new keystroke cancels the plan made by the previous one.

Real-Life Example

A lift door. Every time someone steps in, the door waits a little longer before closing. It only closes once nobody has come in for a few seconds. It does not close and reopen for every person.

Why You Need It

An input listener runs on every character. Typing "Chidinma" fires it eight times. If each one hits the network, you send eight requests for one answer, and they can come back out of order, so the screen ends up showing the result for "Chidinm".

How It Works

  1. Each call runs clearTimeout(timer), which cancels the call planned by the last keystroke.
  2. Then it plans a fresh call with setTimeout.
  3. Only the last keystroke in a burst survives the pause.

debounce takes a function and gives back a new function (functions can return functions). ...args passes along whatever it was called with.

What this code doesWe want to prove that four quick keystrokes produce one search, not four.
app.js
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
// Wrap a function so it only runs after a pause of waitMs
function debounce(fn, waitMs) {
  let timer;
  return (...args) => {
    // Cancel the plan from the previous call
    clearTimeout(timer);
    // Plan a new call
    timer = setTimeout(() => fn(...args), waitMs);
  };
}

// Count how many searches actually run
let searches = 0;
const search = debounce((term) => {
  searches = searches + 1;
  console.log(`search ran for "${term}"`);
}, 60);

// Somebody types C, h, i, d quickly (15ms apart)
['C', 'Ch', 'Chi', 'Chid'].forEach((term, index) => {
  setTimeout(() => search(term), index * 15);
});

// After they stop, count the searches
setTimeout(() => console.log(`searches actually run: ${searches}`), 300);
OUTPUT   TERMINAL
➜ webbo3 node app.js
search ran for "Chid"
searches actually run: 1

The same function in a real search box, with real typing:

What this code doesWe want a debounced search box: the search runs once, after the user stops typing.
index.html
1
2
<input id="search" placeholder="Search">
<p id="result"></p>
app.js
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
function debounce(fn, waitMs) {
  let timer;
  return (...args) => {
    clearTimeout(timer);
    timer = setTimeout(() => fn(...args), waitMs);
  };
}

let runs = 0;

// The expensive work we only want once per pause
const runSearch = debounce((term) => {
  runs = runs + 1;
  document.querySelector('#result').textContent = `Searched for "${term}" (search #${runs})`;
  console.log('search ran:', term);
}, 300);

document.querySelector('#search').addEventListener('input', (event) => {
  runSearch(event.target.value);
});
BROWSERindex.html · after typing "Lagos" and pausing
CONSOLE   BROWSER DEVTOOLS
search ran: Lagos
Debounce or Throttle?

Debounce waits for a pause: right for search boxes and window resizing. Throttle runs at most once every so many milliseconds: right for scroll position and dragging. "When they stop" is debounce; "regularly while it happens" is throttle.

Picture This

Picture the lift door that keeps waiting while people step in, and closes only once the stream of people stops.

4. Paging

Definition

Paging shows a long list a fixed number of items at a time. Page 1 is items 1 to 10, page 2 is 11 to 20, and so on.

Real-Life Example

A textbook does not print all 400 exercises on one sheet. It splits them into pages, tells you how many pages there are, and you turn to the one you need.

How It Works

PieceCodeWhy
Where a page starts(page - 1) * sizePage 1 starts at index 0
The items on ititems.slice(start, start + size)slice copies a section and never fails past the end
How many pagesMath.ceil(total / size)Rounds UP, so the last few items get a page

Array.from({ length: 23 }, (_, i) => ...) builds a list of 23 items. The _ is a parameter we do not need, named so on purpose; {C("i")} is the position.

What this code doesWe want to split 23 results into pages of 10 and see what each page, including one past the end, holds.
app.js
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// 23 results to split into pages of 10
const results = Array.from({ length: 23 }, (_, i) => `Item ${i + 1}`);
const perPage = 10;

// The items on one page
function pageOf(items, page, size) {
  const start = (page - 1) * size;
  return items.slice(start, start + size);
}

// Round UP so the last 3 items get their own page
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:', pageOf(results, 9, perPage));
OUTPUT   TERMINAL
➜ webbo3 node app.js
23 results across 3 pages
page 1: 10 items
page 3: [ 'Item 21', 'Item 22', 'Item 23' ]
page 9: []

Common Mistake: Paging Before Filtering

What this code doesWe want to see why the order of filtering and paging changes what the user sees on page 1.
app.js
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// 20 students; every fourth one is from Kano
const students = Array.from({ length: 20 }, (_, i) => ({
  name: `Student ${i + 1}`,
  city: i % 4 === 0 ? 'Kano' : 'Lagos'
}));
const perPage = 5;
const fromKano = (student) => student.city === 'Kano';

// WRONG: take page 1 first, then filter those 5
const wrong = students.slice(0, perPage).filter(fromKano);

// RIGHT: filter first, then take page 1 of the results
const right = students.filter(fromKano).slice(0, perPage);

console.log('page then filter:', wrong.map((s) => s.name));
console.log('filter then page:', right.map((s) => s.name));
OUTPUT   TERMINAL
➜ webbo3 node app.js
page then filter: [ 'Student 1', 'Student 5' ]
filter then page: [ 'Student 1', 'Student 5', 'Student 9', 'Student 13', 'Student 17' ]
Why This Happens

Paging first cuts five records out of the whole list and then filters only those five, so page 1 shows almost nothing. Narrow first, cut into pages second, and reset to page 1 whenever the search or filter changes.

Picture This

Picture tearing out page 1 of the textbook before highlighting the questions on your topic, instead of highlighting first and then reading the highlighted ones in order.

5. Reshaping an Awkward Response

Definition

Normalising means converting the data an API sends into the shape your code wants, once, as soon as it arrives. The rest of your code then never has to know the API was awkward.

Real-Life Example

A shop receives stock in the supplier's boxes, with the supplier's labels. At the back door, staff unpack it onto the shop's own shelves with the shop's own price tags. Nobody on the shop floor ever sees the supplier's boxes.

What this code doesWe want to turn an awkward API response (split names, null values, numbers sent as text) into clean student records in one step.
app.js
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
// 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 our code wants: one map, at the edge
const students = raw.data.items.map((row) => ({
  id: row.user_id,
  // Join the split name
  name: `${row.first_nm} ${row.last_nm}`,
  // Missing course gets a label instead of null
  course: row.crs ?? 'Unassigned',
  // The score arrived as text: make it a number
  score: Number(row.pts)
}));

console.log(students);
console.log('average:', students.reduce((sum, student) => sum + student.score, 0) / students.length);
OUTPUT   TERMINAL
➜ webbo3 node app.js
[
  { id: 1, name: 'Chidinma Okafor', course: 'Unassigned', score: 88 },
  { id: 2, name: 'Musa Bello', course: 'JS', score: 54 }
]
average: 71

Three real problems fixed in one map: the name was split in two, the course was null for a student with no course, and the score arrived as text, so arithmetic on it would have joined strings instead of adding numbers (Day 1).

Picture This

Picture unpacking at the back door: supplier boxes in, shop shelves out, and the shop floor never sees a supplier label.

6. The Four States, Properly This Time

Definition

Every screen that shows fetched data is always in exactly one of four states: loading, error, empty or showing the list. Your render function decides which, in that order, every time.

Real-Life Example

A departures board at an airport: "Updating" while it refreshes, "Information unavailable" if it loses its feed, "No more departures today" late at night, and otherwise the flights. It is never blank, and it never shows old flights as if they were current.

IfShowNever show
The request is in flightLoading, and disable the buttonA blank screen
It failedWhat went wrong in plain words, and a Retry[object Object], or a stack trace
It worked with zero items"No results for "xyz"" and a way to clearAn empty area with no explanation
It worked with itemsThe listA spinner still turning
What this code doesWe want a search over fetched students where a search that matches nothing shows an honest empty state that quotes the search back.
index.html
1
2
<input id="search" placeholder="Search students">
<div id="view"></div>
app.js
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
const state = { loading: true, error: null, items: [], search: '' };
const view = document.querySelector('#view');

// Decide which of the four states to draw, in order
function render() {
  if (state.loading) {
    view.textContent = 'Loading students...';
    return;
  }
  if (state.error) {
    view.textContent = `Could not load students: ${state.error}`;
    return;
  }
  const shown = state.items.filter((student) =>
    student.name.toLowerCase().includes(state.search.toLowerCase()));
  if (shown.length === 0) {
    view.textContent = `No students match "${state.search}".`;
    return;
  }
  view.innerHTML = shown.map((student) => `<p>${student.name}</p>`).join('');
}

async function load() {
  render();
  try {
    const response = await fetch('https://api.webbo3.test/students');
    if (!response.ok) throw new Error(`status ${response.status}`);
    state.items = await response.json();
  } catch (error) {
    state.error = error.message;
  } finally {
    state.loading = false;
    render();
  }
}

document.querySelector('#search').addEventListener('input', (event) => {
  state.search = event.target.value;
  render();
});

load();
BROWSERindex.html · after searching for "zzzz"
The Empty State Is Not an Error State

A search that matched nothing has worked perfectly. Say so in those terms, name what was searched for so the user can spot their own typo, and give them a way back. An empty list dressed up as a failure teaches people not to trust your app.

Picture This

Picture the departures board: four honest messages, never blank, never stale.

Do This Now

Build a browser for a real public API that returns at least fifty records. Good choices: https://restcountries.com/v3.1/region/africa or https://api.github.com/users/NAME/repos.

  1. Fetch once on load into one array.
  2. Normalise the response into your own shape in one map, before anything else touches it.
  3. A search box, debounced at about 300ms, filtering locally.
  4. A sort control with at least two options, one of them text using localeCompare.
  5. Paging at ten per page, filtering first and resetting to page 1 whenever the search changes.
  6. All four states: loading, error with retry, empty with the search quoted back, and the list.
Prove the States, Do Not Assume Them

Break the URL on purpose and confirm the error state appears. Search for zzzz and confirm the empty state appears. In DevTools, Network tab, throttle to Slow 3G and watch your loading state for a few seconds. If you have not seen all three yourself, you have not built them.

Checkpoint

Q1. Why filter before paging rather than after?

Paging first cuts 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 do inside a debounce?

It cancels the call planned by the previous keystroke. Every new character throws away the pending call and plans 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 small one. Use localeCompare for anything a person will read.

What this code doesCheck your answer: run both sorts.
app.js
1
2
3
// Default sort versus localeCompare
console.log(['ade', 'Zainab'].sort());
console.log(['ade', 'Zainab'].sort((a, b) => a.localeCompare(b)));
OUTPUT   TERMINAL
➜ webbo3 node app.js
[ 'Zainab', 'ade' ]
[ 'ade', 'Zainab' ]
Q4. Zero results came back. Is that an error?

No. The request worked 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
  • ☐ I sort text with localeCompare and wrote a tiebreak with ||
  • ☐ My search is debounced, and I proved it by counting the calls
  • ☐ I filter before paging, and a new search resets the page to 1
  • ☐ The API response is normalised in one place before anything else uses it
  • ☐ I have seen all four states on my own screen, including on Slow 3G

Quick Recap

Fetch once into one array; search, sort and page that array locally. localeCompare sorts text the way people read it, and || writes a tiebreak in one line. Debounce waits for a pause; throttle runs at a steady rate. Filter first, page second, and reset the page when the filter changes. Normalise an awkward response once, at the edge. Loading, error, empty and list are four different screens, and all four are yours to design.

Final Scenario

A student opens your country explorer on a slow connection, types "nig", pauses, and finds nothing because they meant "Nigeria" and typed "nig" into a list sorted and paged in the wrong order.

With today's structure it goes differently: one fetch, a loading message while it arrives, a single search when they pause, results filtered before they are paged, and if nothing matches, a message that shows them exactly what they typed.

Nothing on the screen is ever false. That is what makes real data feel usable.

Tomorrow, Day 10: Git, GitHub and your first group project, and the 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.