Tutorials Logic, IN info@tutorialslogic.com

Real World AJAX Live Search, Infinite Scroll

Production Request Flow

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.

Example 1: Live Search / Autocomplete

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.

Live Search - HTML Structure

Live Search - HTML Structure
<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>

Live Search - JavaScript

Live Search - JavaScript
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';
  }
});

Example 2: Infinite Scroll / Load More

Load additional content when the user scrolls to the bottom of the page - a pattern used by social media feeds and product listings.

Infinite Scroll with IntersectionObserver

Infinite Scroll with IntersectionObserver
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();

Example 3: Real-Time Form Validation

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.

Real-Time Email and Coupon Validation

Real-Time Email and Coupon Validation
// 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.';
  }
});

Example 4: Dynamic Content Loading (Tabs without Page Reload)

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.

AJAX Tabs with Caching

AJAX Tabs with Caching
// 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();

Live Search

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.

Form Submission

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.

Pagination and Updates

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.

Before you move on

Feature Readiness

4 checks
  • Represent initial, loading, empty, success, and failure states.
  • Cancel or ignore obsolete requests.
  • Use server-returned identity and validation results.
  • Apply optimistic updates only with a clear rollback.

Production Gaps

  • Rendering whichever search response arrives last.

    Bind results to the latest query and cancel superseded work.
  • Clearing form values after a validation failure.

    Preserve input and attach server errors to the relevant fields.
  • Optimistically confirming a high-risk operation.

    Wait for the server when rollback would confuse or harm the user.

Try this next

Harden One Feature

0 of 2 completed

Next Step
Next Practice

Finish the concept here, then reinforce it with hands-on coding, interview prep, or a tool that matches the topic.

Browse Free Tutorials

Explore 500+ free tutorials across 20+ languages and frameworks.