API Integration
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. Useasync/await. Handle both network errors (catch block) and HTTP errors (checkresponse.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)withbackground-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.
실제 UI는 데이터를 fetch합니다. 데모 완성과 프로덕션 수준의 차이는 세 가지 상태: 로딩, 성공, 에러에 있습니다.
3-상태 패턴
Claude Code에서 가장 완전한 fetch 코드를 생성하는 프롬프트:
[URL]에서 데이터를 fetch하고,
기다리는 동안 로딩 skeleton 표시,
성공 시 [구조] 렌더링,
실패 시 재시도 버튼이 있는 에러 배너 표시.
async/await 사용. 네트워크 에러(catch)와 HTTP 에러(response.ok 확인) 모두 처리.
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);
}
}
if (!res.ok) throw 줄이 가장 많이 빠뜨리는 세부사항입니다 — fetch는 네트워크 실패에만 reject하며, 404나 500 응답에는 reject하지 않습니다.
AbortController
빠른 클릭이나 내비게이션 시 이전 fetch를 취소하려면 AbortController를 사용해.
새 fetch 시작 전 이전 controller를 abort() 해줘.
데모는 shimmer 스켈레톤, 에러 시뮬레이션, AbortController, 디바운스 검색의 세 가지 섹션을 보여줍니다.
/* ── 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();
})();