API Integration

Lesson 18

Every real UI fetches data. The gap between a demo that works in ideal conditions and one that feels production-ready comes down to three states: loading, success, and error. If you prompt Claude Code for all three explicitly, you get resilient UIs. If you don’t, you get happy-path-only code that breaks silently.

The 3-state pattern

The template prompt that generates the most complete fetch code is:

“Fetch [URL], show a loading skeleton while waiting, render [structure] on success, and show a red error banner with a retry button on failure. Use async/await. Handle both network errors (catch block) and HTTP errors (check response.ok).”

That one prompt produces the full pattern — skeleton, success render, two distinct error types, and retry wiring.

async function loadPosts() {
  showSkeleton();
  try {
    const res = await fetch(url);
    if (!res.ok) throw new Error(`HTTP ${res.status}`);
    const data = await res.json();
    renderPosts(data);
  } catch (err) {
    showError(err.message);
  }
}

The if (!res.ok) throw line is the most commonly missed detail — fetch only rejects on network failure, not on 404 or 500 responses.

AbortController for cancellable requests

When users click rapidly or navigate away, in-flight requests can cause race conditions. AbortController cancels the previous request before starting a new one.

Prompt: “Use AbortController to cancel the previous fetch before starting a new one. Store the controller in a variable, call .abort() if it exists, then create a new one and pass its signal to fetch().”

let controller;

async function load() {
  controller?.abort();
  controller = new AbortController();
  try {
    const res = await fetch(url, { signal: controller.signal });
    // ...
  } catch (err) {
    if (err.name === 'AbortError') return; // ignore cancellation
    showError(err.message);
  }
}

Always check err.name === 'AbortError' in the catch block — abort errors are expected and should not show the error UI.

Skeleton loading states

Skeletons reduce perceived load time by giving the page structure before data arrives. Prompt Claude to match the skeleton geometry to the real card layout:

“Show 6 skeleton cards with the same height and grid layout as the real cards. Use a CSS shimmer animation (background: linear-gradient(90deg, #eee, #f5f5f5, #eee) with background-size: 200% animated via @keyframes).”

Debounce for search inputs

Search inputs that trigger a fetch or filter on every keystroke are expensive. A debounce delays execution until the user pauses.

function debounce(fn, ms) {
  let timer;
  return (...args) => {
    clearTimeout(timer);
    timer = setTimeout(() => fn(...args), ms);
  };
}

input.addEventListener('input', debounce(handleSearch, 200));

200ms is the sweet spot for search — fast enough to feel responsive, slow enough to skip intermediate keystrokes.

What the demo shows

The demo has three sections. Posts list — fetches six posts from JSONPlaceholder with a shimmer skeleton and a “Simulate Error” button to test the error state. User profile — loads on button click with a spinner, renders name, email, company, and an initials avatar. Search with debounce — pre-fetches all 10 users, then filters client-side with a 200ms debounce and a live match count. The posts fetch uses AbortController.

Source Code script.js
/* ── Utilities ────────────────────────────────────────────────── */
function debounce(fn, ms) {
  let timer;
  return (...args) => {
    clearTimeout(timer);
    timer = setTimeout(() => fn(...args), ms);
  };
}

function initials(name) {
  return name.split(' ').slice(0, 2).map(w => w[0]).join('').toUpperCase();
}

/* ── Section 1: Posts list ────────────────────────────────────── */
(function initPosts() {
  const grid        = document.getElementById('postsGrid');
  const errorBanner = document.getElementById('postError');
  const errorMsg    = document.getElementById('postErrorMsg');
  const retryBtn    = document.getElementById('retryPostsBtn');
  const simErrBtn   = document.getElementById('simulateErrorBtn');

  let controller = null;
  let simulateError = false;

  function showSkeleton() {
    errorBanner.hidden = true;
    grid.innerHTML = Array.from({ length: 6 }, () => `
      <div class="post-skel">
        <div class="skel-badge skeleton"></div>
        <div class="skel-title skeleton"></div>
        <div class="skel-title-2 skeleton"></div>
        <div class="skel-body skeleton"></div>
        <div class="skel-body-2 skeleton"></div>
        <div class="skel-link skeleton"></div>
      </div>
    `).join('');
  }

  function showError(msg) {
    grid.innerHTML = '';
    errorMsg.textContent = msg;
    errorBanner.hidden = false;
  }

  function renderPosts(posts) {
    errorBanner.hidden = true;
    grid.innerHTML = posts.map(p => `
      <div class="post-card">
        <span class="post-id">#${p.id}</span>
        <div class="post-title">${p.title}</div>
        <div class="post-body">${p.body}</div>
        <a class="post-link" href="https://jsonplaceholder.typicode.com/posts/${p.id}">Read more →</a>
      </div>
    `).join('');
  }

  async function loadPosts() {
    // Cancel any in-flight request
    controller?.abort();
    controller = new AbortController();

    showSkeleton();

    // Artificial delay so the skeleton is visible
    await new Promise(r => setTimeout(r, 800));

    try {
      if (simulateError) {
        throw new Error('Network request failed (simulated)');
      }

      const url = 'https://jsonplaceholder.typicode.com/posts?_limit=6';
      const res = await fetch(url, { signal: controller.signal });

      if (!res.ok) throw new Error(`HTTP error ${res.status}`);

      const data = await res.json();
      renderPosts(data);
    } catch (err) {
      if (err.name === 'AbortError') return; // intentionally cancelled
      showError(err.message);
    }
  }

  retryBtn.addEventListener('click', () => {
    simulateError = false;
    loadPosts();
  });

  simErrBtn.addEventListener('click', () => {
    simulateError = true;
    loadPosts();
  });

  loadPosts();
})();


/* ── Section 2: User profile ──────────────────────────────────── */
(function initProfile() {
  const area    = document.getElementById('profileArea');
  const loadBtn = document.getElementById('loadProfileBtn');

  function showSpinner() {
    area.innerHTML = '<div class="spinner"></div>';
  }

  function renderProfile(user) {
    const avatar = initials(user.name);
    area.innerHTML = `
      <div class="profile-card">
        <div class="avatar">${avatar}</div>
        <div class="profile-info">
          <div class="profile-name">${user.name}</div>
          <div class="profile-detail">
            ${user.email}<br>
            ${user.company.name}<br>
            <a href="https://${user.website}" target="_blank" rel="noopener">${user.website}</a>
          </div>
        </div>
      </div>
    `;
  }

  async function loadProfile() {
    showSpinner();
    try {
      const res = await fetch('https://jsonplaceholder.typicode.com/users/1');
      if (!res.ok) throw new Error(`HTTP error ${res.status}`);
      const user = await res.json();
      renderProfile(user);
    } catch (err) {
      area.innerHTML = `<p style="color:#991b1b;font-size:.875rem;">Failed to load profile: ${err.message}</p>`;
    }
  }

  loadBtn.addEventListener('click', () => {
    loadBtn.disabled = true;
    loadProfile();
  });
})();


/* ── Section 3: Search with debounce ─────────────────────────── */
(function initUserSearch() {
  const searchInput = document.getElementById('userSearch');
  const userList    = document.getElementById('userList');
  const matchCount  = document.getElementById('matchCount');

  let allUsers = [];

  function renderUsers(users) {
    if (users.length === 0) {
      userList.innerHTML = '<li class="no-results">No users match your search.</li>';
      matchCount.textContent = '0 matches';
      return;
    }

    matchCount.textContent = `${users.length} match${users.length !== 1 ? 'es' : ''}`;
    userList.innerHTML = users.map(u => `
      <li class="user-item">
        <div class="user-avatar">${initials(u.name)}</div>
        <span class="user-name">${u.name}</span>
        <span class="user-email">${u.email}</span>
      </li>
    `).join('');
  }

  function filter(query) {
    const q = query.toLowerCase();
    if (!q) {
      renderUsers(allUsers);
      return;
    }
    const filtered = allUsers.filter(u =>
      u.name.toLowerCase().includes(q) || u.email.toLowerCase().includes(q)
    );
    renderUsers(filtered);
  }

  const debouncedFilter = debounce(filter, 200);

  searchInput.addEventListener('input', e => {
    debouncedFilter(e.target.value);
  });

  async function loadUsers() {
    try {
      const res = await fetch('https://jsonplaceholder.typicode.com/users');
      if (!res.ok) throw new Error(`HTTP error ${res.status}`);
      allUsers = await res.json();
      renderUsers(allUsers);
    } catch (err) {
      userList.innerHTML = `<li style="color:#991b1b;font-size:.875rem;padding:16px 0;">Failed to load users: ${err.message}</li>`;
    }
  }

  loadUsers();
})();