Real AJAX features coordinate more than one fetch call: they validate input, expose loading and retry states, cancel obsolete work, preserve accessibility, and reconcile the server result with local UI state. This lesson applies those concerns to live search, form submission, pagination, and optimistic updates.
The goal is to make each feature predictable under slow networks and failures, not merely demonstrate a successful request on localhost.
A live search box that queries the server as the user types and displays suggestions in a dropdown - debounced to avoid flooding the server with requests.
<div class="search-wrapper" style="position:relative; max-width:400px;">
<input type="text" id="live-search" placeholder="Search products..."
autocomplete="off" class="tl-form-control">
<ul id="search-results" style="
position:absolute; top:100%; left:0; right:0;
background:#fff; border:1px solid #ddd; border-radius:4px;
list-style:none; margin:0; padding:0; z-index:100; display:none;">
</ul>
</div>
const input = document.getElementById('live-search');
const resultsList = document.getElementById('search-results');
let debounceTimer;
let currentController = null;
input.addEventListener('input', function () {
const query = this.value.trim();
clearTimeout(debounceTimer);
// Cancel any in-flight request
if (currentController) currentController.abort();
if (query.length < 2) {
resultsList.style.display = 'none';
resultsList.innerHTML = '';
return;
}
debounceTimer = setTimeout(async () => {
currentController = new AbortController();
try {
const res = await fetch(
`/api/search?q=${encodeURIComponent(query)}`,
{ signal: currentController.signal }
);
const items = await res.json();
if (items.length === 0) {
resultsList.innerHTML = '<li style="padding:8px 12px;color:#999">No results</li>';
} else {
resultsList.innerHTML = items.map(item => `
<li data-id="${item.id}" style="padding:8px 12px;cursor:pointer;border-bottom:1px solid #eee"
onmouseover="this.style.background='#f5f5f5'"
onmouseout="this.style.background=''">
${item.name}
</li>
`).join('');
resultsList.querySelectorAll('li[data-id]').forEach(li => {
li.addEventListener('click', () => {
input.value = li.textContent.trim();
resultsList.style.display = 'none';
console.log('Selected ID:', li.dataset.id);
});
});
}
resultsList.style.display = 'block';
} catch (err) {
if (err.name !== 'AbortError') console.error('Search error:', err);
}
}, 300);
});
// Hide results when clicking outside
document.addEventListener('click', e => {
if (!e.target.closest('.search-wrapper')) {
resultsList.style.display = 'none';
}
});
Load additional content when the user scrolls to the bottom of the page - a pattern used by social media feeds and product listings.
let currentPage = 1;
let isLoading = false;
let hasMore = true;
const feed = document.getElementById('post-feed');
const sentinel = document.getElementById('scroll-sentinel'); // empty div at bottom
// IntersectionObserver fires when sentinel enters the viewport
const observer = new IntersectionObserver(async (entries) => {
if (entries[0].isIntersecting && !isLoading && hasMore) {
await loadMorePosts();
}
}, { threshold: 0.1 });
observer.observe(sentinel);
async function loadMorePosts() {
isLoading = true;
sentinel.innerHTML = '<div class="spinner">Loading...</div>';
try {
const res = await fetch(`/api/posts?page=${currentPage}&limit=10`);
const { posts, totalPages } = await res.json();
posts.forEach(post => {
const article = document.createElement('article');
article.className = 'post-card';
article.innerHTML = `
<h3>${post.title}</h3>
<p>${post.excerpt}</p>
<small>By ${post.author} - ${post.date}</small>
`;
feed.appendChild(article);
});
currentPage++;
hasMore = currentPage <= totalPages;
sentinel.innerHTML = hasMore
? '' // clear spinner, observer will trigger again
: '<p style="text-align:center;color:#999">No more posts</p>';
} catch (err) {
sentinel.innerHTML = '<p class="tl-text-danger">Failed to load posts</p>';
console.error(err);
} finally {
isLoading = false;
}
}
// Load first page on startup
loadMorePosts();
Validate form fields against the server in real time - checking for duplicate emails, weak passwords, or invalid coupon codes as the user fills in the form.
// Reusable debounced validator
function createValidator(inputEl, feedbackEl, endpoint, paramName, minLength = 3) {
let timer;
inputEl.addEventListener('blur', () => validate()); // also validate on blur
inputEl.addEventListener('input', () => {
clearTimeout(timer);
const value = inputEl.value.trim();
if (value.length < minLength) {
setFeedback(feedbackEl, '', 'neutral');
return;
}
setFeedback(feedbackEl, 'Checking...', 'neutral');
timer = setTimeout(() => validate(), 400);
});
async function validate() {
const value = inputEl.value.trim();
if (value.length < minLength) return;
try {
const res = await fetch(`${endpoint}?${paramName}=${encodeURIComponent(value)}`);
const { valid, message } = await res.json();
setFeedback(feedbackEl, message, valid ? 'success' : 'error');
inputEl.dataset.valid = valid;
} catch {
setFeedback(feedbackEl, 'Could not validate', 'neutral');
}
}
}
function setFeedback(el, message, type) {
el.textContent = message;
el.className = `feedback feedback-${type}`;
}
// Initialize validators
createValidator(
document.getElementById('email'),
document.getElementById('email-feedback'),
'/api/validate/email', 'email', 5
);
createValidator(
document.getElementById('coupon'),
document.getElementById('coupon-feedback'),
'/api/validate/coupon', 'code', 4
);
// Prevent form submission if any field is invalid
document.getElementById('register-form').addEventListener('submit', function (e) {
const fields = this.querySelectorAll('[data-valid]');
const allValid = Array.from(fields).every(f => f.dataset.valid === 'true');
if (!allValid) {
e.preventDefault();
document.getElementById('form-error').textContent = 'Please fix the errors above.';
}
});
Load tab content on demand via AJAX - only fetching data when the user clicks a tab, and caching it so subsequent clicks don't re-fetch.
// HTML structure expected:
// <div class="tab-nav">
// <button class="tab-btn active" data-tab="overview" data-url="/api/tabs/overview">Overview</button>
// <button class="tab-btn" data-tab="reviews" data-url="/api/tabs/reviews">Reviews</button>
// <button class="tab-btn" data-tab="specs" data-url="/api/tabs/specs">Specs</button>
// </div>
// <div id="tab-content"></div>
const tabCache = {};
const tabContent = document.getElementById('tab-content');
document.querySelectorAll('.tab-btn').forEach(btn => {
btn.addEventListener('click', async function () {
const tabId = this.dataset.tab;
const url = this.dataset.url;
// Update active state
document.querySelectorAll('.tab-btn').forEach(b => b.classList.remove('active'));
this.classList.add('active');
// Serve from cache if available
if (tabCache[tabId]) {
tabContent.innerHTML = tabCache[tabId];
return;
}
// Show loading state
tabContent.innerHTML = '<div class="tab-loading"><span class="spinner"></span> Loading...</div>';
try {
const res = await fetch(url);
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const html = await res.text(); // tabs return HTML fragments
tabCache[tabId] = html; // cache for future clicks
tabContent.innerHTML = html;
} catch (err) {
tabContent.innerHTML = `<p class="tl-text-danger">Failed to load tab: ${err.message}</p>`;
}
});
});
// Load the default active tab on page load
const defaultTab = document.querySelector('.tab-btn.active');
if (defaultTab) defaultTab.click();
Debounce text input so a short pause produces one request instead of a request per keystroke. Abort the previous request when the query changes, encode the query with URLSearchParams, and associate every result with the query that created it. Render a distinct empty-result message rather than treating an empty list as a failure.
Use an aria-live region for concise result-count updates and keep keyboard focus in the search field. Do not replace the entire page on every response. Cache only when the data’s freshness policy permits it, and clear user-specific caches when identity changes.
Perform quick client validation for immediate feedback, then treat server validation as authoritative. Disable the submit control while one request is active, but keep field values available. Map structured validation errors to the corresponding controls and move focus to a useful summary only when that improves keyboard navigation.
On success, use the representation returned by the server instead of inventing an ID or timestamp on the client. For a create operation vulnerable to duplicate retries, use an idempotency mechanism supported by the API. Never assume a timed-out write did not reach the server.
For pagination, keep the current cursor or page in the URL when users should be able to bookmark and navigate with back and forward. Merge results by stable identifier and prevent duplicate load-more requests. Cursor pagination is often safer than page numbers when records are inserted frequently, but the API decides which model is available.
An optimistic update changes the screen before the server confirms the write. Use it only when rollback is understandable and conflicts are rare. Keep the previous value, reconcile with the server response, and restore or mark the item when the write fails. Payments and destructive operations usually deserve explicit confirmation instead of optimistic success.
Try this next
0 of 2 completed
Explore 500+ free tutorials across 20+ languages and frameworks.