JavaScript Practice #3 fetch and Async UI
In #2 Event Handling and Delegation, you saw the tools for receiving user input. This time we look at what happens next — taking that input, fetching external data, and reflecting it on screen. The practical patterns of async UI.
Start with the simplest form #
<button id="load">Load users</button>
<ul id="user-list"></ul>const button = document.querySelector('#load');
const list = document.querySelector('#user-list');
button.addEventListener('click', async () => {
const res = await fetch('/api/users');
const users = await res.json();
list.innerHTML = '';
for (const user of users) {
const li = document.createElement('li');
li.textContent = user.name;
list.append(li);
}
});A combination of Intermediate #6 fetch API + Practice #1 DOM Manipulation. It works, but in a real UI it’s missing loading indication, error handling, and protection against duplicate clicks.
Loading and errors — three states #
Data loading usually has these states.
- idle — not called yet
- loading — in progress
- success — has data
- error — failed
Here’s the pattern for reflecting them in the UI.
button.addEventListener('click', async () => {
list.innerHTML = '<li class="loading">Loading...</li>';
button.disabled = true;
try {
const res = await fetch('/api/users');
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const users = await res.json();
list.innerHTML = '';
for (const user of users) {
const li = document.createElement('li');
li.textContent = user.name;
list.append(li);
}
} catch (err) {
list.innerHTML = `<li class="error">Error: ${err.message}</li>`;
} finally {
button.disabled = false;
}
});Three things were added.
- Loading indicator —
<li class="loading">Loading...</li> - Error message —
<li class="error">... - Disabled button —
button.disabled = trueprevents duplicate clicks; restored infinally
The finally block is the key. Button restoration is guaranteed regardless of success or failure.
Search input — reacting to live input #
Think of a search autocomplete. Calling fetch on every keystroke sends way too many requests.
<input id="search" placeholder="Search...">
<ul id="results"></ul>The simplest (but inefficient) version #
input.addEventListener('input', async (e) => {
const q = e.target.value;
if (!q) {
results.innerHTML = '';
return;
}
const res = await fetch(`/api/search?q=${encodeURIComponent(q)}`);
const items = await res.json();
// ... update DOM
});Two problems:
- ‘h’, ‘he’, ‘hel’, …, ‘hello’ — fetch on every key, wasted traffic
- The ‘h’ response can arrive late and overwrite the ‘hello’ result
Debounce — collapse rapid input into one #
function debounce(fn, delay) {
let timer;
return (...args) => {
clearTimeout(timer);
timer = setTimeout(() => fn(...args), delay);
};
}
const onSearch = debounce(async (q) => {
if (!q) {
results.innerHTML = '';
return;
}
const res = await fetch(`/api/search?q=${encodeURIComponent(q)}`);
const items = await res.json();
// ... update DOM
}, 300);
input.addEventListener('input', (e) => onSearch(e.target.value));This is the pattern from Advanced #1 Closures. The actual call only happens once 300ms have passed after typing stops. Type rapidly and only one request goes out.
AbortController — preventing late responses #
Debouncing alone can’t prevent the second problem (a late response overwriting a newer result). You need to cancel the previous fetch.
let currentController = null;
const onSearch = debounce(async (q) => {
if (!q) {
results.innerHTML = '';
return;
}
// Cancel previous request
if (currentController) currentController.abort();
currentController = new AbortController();
try {
const res = await fetch(`/api/search?q=${encodeURIComponent(q)}`, {
signal: currentController.signal,
});
const items = await res.json();
// ... update DOM
} catch (err) {
if (err.name === 'AbortError') return; // ignore cancelled requests
console.error(err);
}
}, 300);
input.addEventListener('input', (e) => onSearch(e.target.value));When a new request comes in, the previous one is aborted. Late-arriving responses throw an AbortError and get filtered out in the catch block.
These two tools (debounce + AbortController) are the standard combination for search UIs.
AbortSignal.timeout — adding a timeout
#
Auto-cancel if a request takes too long.
const res = await fetch(url, {
signal: AbortSignal.timeout(5000),
});The tool you saw in Intermediate #6. Combined with debounce and AbortController, you can build an even more robust search UI.
Combining two signals — AbortSignal.any
#
If you want to handle both timeout and user cancellation:
const userController = new AbortController();
const signal = AbortSignal.any([
userController.signal,
AbortSignal.timeout(5000),
]);
const res = await fetch(url, { signal });
// Cancelled by either userController.abort() or 5s timeout
ES2024’s AbortSignal.any is the latest standard tool.
Preventing duplicate requests — in-flight cache #
When the same data is requested simultaneously from multiple places, make only one call go out.
const inflight = new Map();
function fetchOnce(url) {
if (inflight.has(url)) {
return inflight.get(url);
}
const promise = fetch(url)
.then((r) => r.json())
.finally(() => inflight.delete(url));
inflight.set(url, promise);
return promise;
}
// Called from two places at the same time
const a = fetchOnce('/api/me');
const b = fetchOnce('/api/me');
// fetch only runs once; a and b share the same Promise
Store an in-flight Promise in a Map — when the same URL is requested again, return that Promise. This is one of the things libraries like TanStack Query do internally.
Pagination / infinite scroll patterns #
let page = 1;
async function loadMore() {
loadMoreBtn.disabled = true;
const res = await fetch(`/api/posts?page=${page}`);
const posts = await res.json();
for (const p of posts) {
const li = document.createElement('li');
li.textContent = p.title;
list.append(li);
}
page++;
loadMoreBtn.disabled = false;
if (posts.length === 0) {
loadMoreBtn.remove(); // remove the button when there's nothing more
}
}
loadMoreBtn.addEventListener('click', loadMore);
loadMore(); // run once initially
Infinite scroll — IntersectionObserver #
Auto-load when the scroll gets close to the end.
const sentinel = document.querySelector('.sentinel');
const observer = new IntersectionObserver((entries) => {
if (entries[0].isIntersecting) {
loadMore();
}
});
observer.observe(sentinel);.sentinel is an empty element placed at the end of the list. When it enters the viewport, the callback runs. Far more efficient than the old pattern of computing coordinates manually with the scroll event.
Optimistic Update #
A pattern that updates the UI before waiting for the server response. Useful for giving immediate feedback.
likeBtn.addEventListener('click', async () => {
// Optimistically update UI immediately
likeBtn.classList.add('liked');
countEl.textContent = parseInt(countEl.textContent) + 1;
try {
await fetch('/api/like', { method: 'POST' });
} catch (err) {
// Roll back on failure
likeBtn.classList.remove('liked');
countEl.textContent = parseInt(countEl.textContent) - 1;
alert('Like failed');
}
});It works well for actions where success is overwhelmingly likely (likes, bookmarks). It’s not a good fit when failures are frequent — the rollback flicker becomes annoying.
Stages of error handling #
1) Network stage #
try {
const res = await fetch(url);
// ...
} catch (err) {
if (err.name === 'TypeError') {
// Network itself failed (offline, DNS, etc.)
showOffline();
} else if (err.name === 'AbortError') {
// Cancelled
return;
} else {
throw err;
}
}2) HTTP status #
if (!res.ok) {
if (res.status === 401) return redirectToLogin();
if (res.status === 404) return showNotFound();
if (res.status >= 500) return showServerError();
throw new Error(`HTTP ${res.status}`);
}3) Response body parsing / validation #
const data = await res.json();
if (!data || typeof data.id !== 'string') {
throw new Error('Invalid response shape');
}All three stages occur at different points and require different handling. A small wrapper function tidies up every call site.
A small wrapper — your app’s fetch contract #
export async function api(url, options = {}) {
let res;
try {
res = await fetch(url, {
headers: { 'Content-Type': 'application/json', ...options.headers },
...options,
});
} catch (err) {
if (err.name === 'AbortError') throw err;
throw new Error(`Network error: ${err.message}`);
}
if (!res.ok) {
throw new Error(`HTTP ${res.status}`);
}
return res.json();
}
export const apiGet = (url, signal) => api(url, { signal });
export const apiPost = (url, data, signal) => api(url, {
method: 'POST',
body: JSON.stringify(data),
signal,
});Even this much makes call sites very clean.
import { apiGet } from './api.js';
try {
const users = await apiGet('/api/users');
// ...
} catch (err) {
showError(err.message);
}Wrapping up #
What this post covered:
- Reflecting the three states (loading/error/success) in the UI
try/catch/finally+ adisabledtoggle to prevent duplicate clicks- Debouncing to reduce input frequency
- AbortController to cancel late responses
- Composing timeouts with
AbortSignal.timeout/AbortSignal.any - An in-flight cache to prevent duplicate requests
- IntersectionObserver for infinite scroll
- The optimistic update pattern
- Errors come in three stages — network / status / body
- A small wrapper function tidies up call sites
The next post (#4 Working with Forms) covers form input validation, working with FormData, and patterns at submit time.