Skip to main content

Skillber v1.0 is here!

Learn more

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;
}
}
// Usage
const 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

StateUI PatternWhen
LoadingSpinner / skeletonWhile request is in flight
SuccessRendered dataRequest succeeded
Empty”No results” messageRequest succeeded but data is empty
ErrorError message + retry buttonRequest failed

Practice Exercises

  1. User directory: Fetch from https://jsonplaceholder.typicode.com/users and render a directory of user cards with name, email, phone, and company. Add loading, error, and empty states.

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

  3. Infinite scroll: Create a page that loads more content as the user scrolls to the bottom. Use the scroll or IntersectionObserver event to detect when the bottom is near, then fetch the next page of data.