Install any skill in seconds. Free to start, no credit card required.
Get Started Free →Production-ready patterns for integrating frontend applications with backend APIs, including race condition handling, request cancellation, retry strategies, error normalization, and UI state management.
.claude/skills/frontend-api-integration-patterns/SKILL.md| Model | Eval pass | Runs |
|---|---|---|
| gemini-3.6-flashlowest | 48% | 25 |
| gemini-3.1-pro-preview | 100% | 1 |
| Test case | Without → With | Effect | Δ tokens | Δ turns |
|---|---|---|---|---|
| case-01 | ✗→✓ | ▲ Improved | — | — |
| case-02 | ✓→✓ | = Same ✓ | — | — |
| case-18 | ✗→✗ | = Same ✗ | — | — |
| case-17 | ✗→✗ | = Same ✗ | — | — |
| case-16 | ✗→✗ | = Same ✗ | — | — |
This skill provides production-ready patterns for integrating frontend applications with backend APIs.
Most frontend issues are not caused by APIs being difficult to call, but by incorrect handling of asynchronous behavior—leading to race conditions, stale data, duplicated requests, and poor user experience.
This skill focuses on correctness, resilience, and user experience, not just making API calls work.
/predict, /recommend)Centralize API logic and normalize errors.
js id="k1m7r2"export class ApiError extends Error { constructor(message, status, payload = null) { super(message); this.name = "ApiError"; this.status = status; this.payload = payload; } } export const apiClient = async (url, options = {}) => { const res = await fetch(url, { headers: { "Content-Type": "application/json" }, ...options, }); if (!res.ok) { let payload = null; try { payload = await res.json(); } catch (_) {} throw new ApiError( payload?.message || "Request failed", res.status, payload ); } // handle empty responses safely (e.g. 204 No Content) if (res.status === 204) return null; const text = await res.text(); return text ? JSON.parse(text) : null; };
Prevent stale responses from overwriting fresh data.
js id="y7p4ha"useEffect(() => { let cancelled = false; const load = async () => { try { setLoading(true); setError(null); const result = await getUser(); if (!cancelled) setData(result); } catch (err) { if (!cancelled) setError(err.message); } finally { if (!cancelled) setLoading(false); } }; load(); return () => { cancelled = true; }; }, []);
> Use a cancellation flag for non-fetch async logic. For network requests, prefer AbortController.
Cancel in-flight requests to avoid memory leaks and stale updates.
js id="l9x2pw"useEffect(() => { const controller = new AbortController(); const load = async () => { try { const data = await getUser({ signal: controller.signal }); setData(data); } catch (err) { if (err.name === "AbortError") return; setError(err.message); } }; load(); return () => controller.abort(); }, [userId]);
Retry only transient failures (5xx or network errors).
js id="8n3zcf"const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); const fetchWithBackoff = async (fn, retries = 3, delay = 300) => { try { return await fn(); } catch (err) { const isAbort = err.name === "AbortError"; const isHttpError = typeof err.status === "number"; const isRetryable = !isAbort && (!isHttpError || err.status >= 500); if (retries <= 0 || !isRetryable) throw err; const nextDelay = delay * 2 + Math.random() * 100; await sleep(nextDelay); return fetchWithBackoff(fn, retries - 1, nextDelay); } };
Avoid excessive API calls (e.g., search inputs).
js id="i2r7wq"const useDebounce = (value, delay = 400) => { const [debounced, setDebounced] = useState(value); useEffect(() => { const t = setTimeout(() => setDebounced(value), delay); return () => clearTimeout(t); }, [value, delay]); return debounced; };
Prevent duplicate API calls across components.
js id="x8v4km"const inFlight = new Map(); export const dedupedFetch = (key, fn) => { if (inFlight.has(key)) return inFlight.get(key); const promise = fn().finally(() => inFlight.delete(key)); inFlight.set(key, promise); return promise; };
js id="n5q2pt"const controllerRef = useRef(null); const handlePredict = async (input) => { controllerRef.current?.abort(); controllerRef.current = new AbortController(); try { const result = await fetchWithBackoff(() => apiClient("/predict", { method: "POST", body: JSON.stringify({ text: input }), signal: controllerRef.current.signal, }) ); setOutput(result); } catch (err) { if (err.name === "AbortError") return; setError(err.message); } };
js id="w4z8yn"const debouncedQuery = useDebounce(query, 400); useEffect(() => { if (!debouncedQuery) return; const controller = new AbortController(); searchAPI(debouncedQuery, { signal: controller.signal }) .then(setResults) .catch((err) => { if (err.name !== "AbortError") { setError("Search failed. Please try again."); } }); return () => controller.abort(); }, [debouncedQuery]);
js id="q2k9hz"const deleteItem = async (id) => { const previous = items; setItems((curr) => curr.filter((item) => item.id !== id)); try { await apiClient(`/items/${id}`, { method: "DELETE" }); } catch (err) { setItems(previous); setError("Delete failed. Please try again."); } };
Problem: UI shows stale data Solution: Use cancellation or guard against outdated responses
Problem: Too many API calls on input Solution: Use debouncing + cancellation
Problem: Duplicate requests from multiple components Solution: Use request deduplication
Problem: Server overload during retry Solution: Use exponential backoff
Problem: State updates after component unmount Solution: Use AbortController cleanup
| Case | Status | Duration (ms) | Turns | Tokens | Tool calls | ||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| Without | With | Δ | Without | With | Δ | Without | With | Δ | Without | With | Δ | ||
case-18 | fail→fail | — | — | — | — | — | — | — | — | — | — | — | — |
case-17 | fail→fail | — | — | — | — | — | — | — | — | — | — | — | — |
case-16 | fail→fail | — | — | — | — | — | — | — | — | — | — | — | — |
case-12 | fail→fail | — | — | — | — | — | — | — | — | — | — | — | — |
case-13 | fail→fail | — | — | — | — | — | — | — | — | — | — | — | — |
case-01 | fail→pass | — | — | — | — | — | — | — | — | — | — | — | — |
case-19 | fail→fail | — | — | — | — | — | — | — | — | — | — | — | — |
case-04 | fail→fail | — | — | — | — | — | — | — | — | — | — | — | — |
case-21 | fail→fail | — | — | — | — | — | — | — | — | — | — | — | — |
case-08 | fail→fail | — | — | — | — | — | — | — | — | — | — | — | — |
case-10 | fail→fail | — | — | — | — | — | — | — | — | — | — | — | — |
case-05 | fail→fail | — | — | — | — | — | — | — | — | — | — | — | — |
case-02 | pass→pass | — | — | — | — | — | — | — | — | — | — | — | — |
case-11 | fail→fail | — | — | — | — | — | — | — | — | — | — | — | — |
case-03 | fail→fail | — | — | — | — | — | — | — | — | — | — | — | — |
case-06 | fail→fail | — | — | — | — | — | — | — | — | — | — | — | — |
case-07 | fail→fail | — | — | — | — | — | — | — | — | — | — | — | — |
case-09 | fail→fail | — | — | — | — | — | — | — | — | — | — | — | — |
case-14 | fail→fail | — | — | — | — | — | — | — | — | — | — | — | — |
case-15 | fail→fail | — | — | — | — | — | — | — | — | — | — | — | — |
case-20 | fail→fail | — | — | — | — | — | — | — | — | — | — | — | — |
case-22 | fail→fail | — | — | — | — | — | — | — | — | — | — | — | — |
DecimalAI ran this skill against gemini-3.6-flash twice over the same eval suite — once with the skill loaded and once without — and compared the two runs case by case. 22 cases were attempted. The headline lift of +5 percentage points is the difference between those two pass rates over the 22 comparable cases.
The per-case answers from this run were removed by the retention sweep, so the case table below shows the verdicts without the text either arm produced. The counts above were recorded at the time and are unaffected. Answers are now kept for 180 days.
Other measured skills in the registry, with their headline benchmark lift.