The Fetch API in Practice
Checking access...
The Fetch API is the modern way to make HTTP requests from the browser. This page focuses on practical patterns — fetching data and rendering it on the page, handling all states (loading, empty, error), and avoiding common pitfalls.
Basic Data Fetching Pattern
<div id="app"> <button id="load-btn">Load Users</button> <div id="content"></div></div>const loadBtn = document.getElementById("load-btn");const content = document.getElementById("content");
loadBtn.addEventListener("click", async () => { // 1. Loading state content.innerHTML = '<p class="loading">Loading users...</p>';
try { // 2. Fetch data const response = await fetch("https://jsonplaceholder.typicode.com/users");
if (!response.ok) { throw new Error(`HTTP ${response.status}: ${response.statusText}`); }
const users = await response.json();
// 3. Empty state if (users.length === 0) { content.innerHTML = '<p class="empty">No users found.</p>'; return; }
// 4. Render data content.innerHTML = ` <ul class="user-list"> ${users.map((user) => ` <li class="user-card"> <h3>${escapeHtml(user.name)}</h3> <p>${escapeHtml(user.email)}</p> <p>${escapeHtml(user.company.name)}</p> </li> `).join("")} </ul> `; } catch (error) { // 5. Error state content.innerHTML = ` <div class="error"> <p>Failed to load users: ${escapeHtml(error.message)}</p> <button onclick="location.reload()">Try Again</button> </div> `; }});
function escapeHtml(str) { const div = document.createElement("div"); div.textContent = str; return div.innerHTML;}Loading States Pattern
class DataLoader { constructor(container) { this.container = container; }
showLoading(message = "Loading...") { this.container.innerHTML = ` <div class="loading-state"> <div class="spinner"></div> <p>${escapeHtml(message)}</p> </div> `; }
showError(message, onRetry) { this.container.innerHTML = ` <div class="error-state"> <p>⚠️ ${escapeHtml(message)}</p> <button class="retry-btn">Try Again</button> </div> `;
if (onRetry) { this.container.querySelector(".retry-btn") .addEventListener("click", onRetry); } }
showEmpty(message = "No data available") { this.container.innerHTML = ` <div class="empty-state"> <p>${escapeHtml(message)}</p> </div> `; }
render(html) { this.container.innerHTML = html; }}
// Usageconst loader = new DataLoader(document.getElementById("content"));
async function loadPosts() { loader.showLoading("Fetching posts...");
try { const response = await fetch("https://jsonplaceholder.typicode.com/posts");
if (!response.ok) throw new Error("Failed to fetch posts");
const posts = await response.json();
if (posts.length === 0) { loader.showEmpty("No posts published yet"); return; }
loader.render(` <div class="posts-grid"> ${posts.slice(0, 10).map((post) => ` <article class="post-card"> <h2>${escapeHtml(post.title)}</h2> <p>${escapeHtml(post.body.slice(0, 100))}...</p> </article> `).join("")} </div> `); } catch (error) { loader.showError(error.message, () => loadPosts()); }}
loadPosts();Form Submission with Fetch
<form id="create-post"> <input type="text" name="title" placeholder="Post title" required /> <textarea name="body" placeholder="Post content" required></textarea> <button type="submit">Create Post</button></form><div id="result"></div>const form = document.getElementById("create-post");const result = document.getElementById("result");
form.addEventListener("submit", async (event) => { event.preventDefault();
const formData = new FormData(form); const data = Object.fromEntries(formData);
// Disable button during submission const submitBtn = form.querySelector("button"); submitBtn.disabled = true; submitBtn.textContent = "Submitting...";
result.innerHTML = '<p class="loading">Creating post...</p>';
try { const response = await fetch("https://jsonplaceholder.typicode.com/posts", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(data), });
if (!response.ok) { const errorData = await response.json().catch(() => ({})); throw new Error(errorData.message || `HTTP ${response.status}`); }
const created = await response.json();
result.innerHTML = ` <div class="success"> <p>✅ Post created! (ID: ${created.id})</p> <p><strong>${escapeHtml(created.title)}</strong></p> </div> `;
form.reset(); } catch (error) { result.innerHTML = ` <div class="error"> <p>❌ ${escapeHtml(error.message)}</p> </div> `; } finally { submitBtn.disabled = false; submitBtn.textContent = "Create Post"; }});Tip
Always disable submit buttons during form submission to prevent double-submits. Re-enable in the finally block so it runs whether the request succeeds or fails.
Race Condition Prevention
When making sequential requests (e.g., search as you type), you need to handle race conditions:
const searchInput = document.getElementById("search");const results = document.getElementById("results");
let currentRequest = 0;
searchInput.addEventListener("input", async () => { const query = searchInput.value.trim(); if (query.length < 3) { results.innerHTML = ""; return; }
// Track this request const requestId = ++currentRequest;
results.innerHTML = '<p class="loading">Searching...</p>';
try { const response = await fetch( `https://api.example.com/search?q=${encodeURIComponent(query)}` ); const data = await response.json();
// Only update if this is still the most recent request if (requestId === currentRequest) { renderResults(data); } } catch (error) { if (requestId === currentRequest) { results.innerHTML = `<p class="error">Search failed: ${escapeHtml(error.message)}</p>`; } }});Aborting Requests
For more robust cancellation, use AbortController:
let abortController = null;
searchInput.addEventListener("input", async () => { const query = searchInput.value.trim();
// Cancel previous request if (abortController) { abortController.abort(); }
// Create new controller for this request abortController = new AbortController();
try { const response = await fetch( `https://api.example.com/search?q=${encodeURIComponent(query)}`, { signal: abortController.signal } ); const data = await response.json(); renderResults(data); } catch (error) { if (error.name === "AbortError") { return; // silently ignore — this is expected } showError(error.message); }});Pagination
let currentPage = 1;const perPage = 10;
async function loadPage(page) { loader.showLoading(`Loading page ${page}...`);
try { const response = await fetch( `https://jsonplaceholder.typicode.com/posts?_page=${page}&_limit=${perPage}` ); const posts = await response.json(); const total = parseInt(response.headers.get("x-total-count") || "0"); const totalPages = Math.ceil(total / perPage);
if (posts.length === 0) { loader.showEmpty("No more posts"); return; }
loader.render(` <div class="posts"> ${posts.map((post) => ` <div class="post"> <h3>${escapeHtml(post.title)}</h3> <p>${escapeHtml(post.body)}</p> </div> `).join("")} </div> <div class="pagination"> <button ${page <= 1 ? "disabled" : ""} onclick="loadPage(${page - 1})"> ← Previous </button> <span>Page ${page} of ${totalPages}</span> <button ${page >= totalPages ? "disabled" : ""} onclick="loadPage(${page + 1})"> Next → </button> </div> `); } catch (error) { loader.showError(error.message, () => loadPage(page)); }}
loadPage(1);Quick Reference
| State | UI Pattern | When |
|---|---|---|
| Loading | Spinner / skeleton | While request is in flight |
| Success | Rendered data | Request succeeded |
| Empty | ”No results” message | Request succeeded but data is empty |
| Error | Error message + retry button | Request failed |
Practice Exercises
User directory: Fetch from
https://jsonplaceholder.typicode.com/usersand render a directory of user cards with name, email, phone, and company. Add loading, error, and empty states.Search-as-you-type: Build an input that fetches from a posts endpoint as the user types. Debounce the input (delay 300ms), cancel previous requests, and render matching post titles.
Infinite scroll: Create a page that loads more content as the user scrolls to the bottom. Use the
scrollorIntersectionObserverevent to detect when the bottom is near, then fetch the next page of data.