Frontend API Integration Patterns
Overview
Use this skill when frontend code must call backend APIs reliably under real user behavior: fast navigation, repeated clicks, changing filters, intermittent failures, expired sessions, and overlapping requests.
This skill focuses on production API integration behavior in the UI layer:
- choosing the correct loading mechanism for the framework lifecycle
- preventing stale UI from out-of-order responses
- cancelling work that is no longer relevant
- retrying only when the request is safe to repeat
- normalizing backend and network failures into a stable UI-facing error contract
- handling credentialed requests without weakening browser security boundaries
It is most useful for React-style applications, but the patterns apply to any frontend that performs asynchronous API work.
When to Use
Use this skill when one or more of these conditions apply:
- the UI fetches data from REST or HTTP-style APIs
- users can trigger overlapping requests by typing, filtering, navigating, or clicking repeatedly
- stale responses sometimes overwrite newer state
- the app needs cancellation when components unmount or route parameters change
- retries are needed for transient failures, but not all requests are safe to replay
- backend errors need to be converted into a predictable frontend shape
- the app sends cookies or other credentials and must respect CORS and CSRF boundaries
- a review is needed for existing frontend API code that “mostly works” but fails under edge cases
Do not use this skill as the primary guide when:
- the task is backend API design with no frontend behavior involved
- the integration is purely server-to-server
- the system uses only static build-time data with no user-driven requests
- the app already has an established framework-native data layer and the task is unrelated to request behavior
Workflow
Choose the data-loading owner
- Prefer framework or router-native loaders/actions when the framework already owns navigation and data lifecycle.
- Prefer a query library when the app needs caching, revalidation, deduplication, mutations, and background refresh across many screens.
- Use direct client-side fetch logic only for local, component-scoped interactions that do not justify broader infrastructure.
- Avoid defaulting to
useEffectfor everything; effects are often the fallback, not the ideal first choice.
Define the request contract before coding
- Record the endpoint, method, auth mode, request body, and expected success shape.
- Decide whether the operation is idempotent and whether retry is allowed.
- Define the normalized frontend error shape up front, for example:
type UiApiError = { kind: 'http' | 'network' | 'timeout' | 'abort' | 'parse' | 'unknown'; status?: number; code?: string; title: string; detail?: string; retriable: boolean; requestId?: string; };- If the backend returns RFC 9457-style problem details, preserve useful fields instead of flattening everything into a generic message.
- Use
assets/schema-map.jsonas the canonical field mapping when converting transport and backend errors into UI errors.
Prevent stale-response races
- Give each request a lifecycle boundary tied to the current route, filter set, search term, or selected entity.
- Cancel superseded requests with
AbortControllerwhen the browser API or client supports it. - Also guard state updates with a request token or sequence check, because cancellation is not a complete stale-state strategy by itself.
- Treat “latest request wins” as an explicit decision, not accidental behavior.
Implement loading and mutation state deliberately
- Separate initial load, background refresh, empty state, success state, and error state.
- Do not clear good data just because a refresh started.
- Distinguish user-visible blocking work from silent background revalidation.
- For mutations, model at least: idle, submitting, succeeded, failed, and optionally rolling back.
Retry only when safe
- Retry transient failures such as network interruption,
408,429, and many5xxresponses. - Respect
Retry-Afterwhen the server sends it. - Usually do not auto-retry unsafe mutations like
POSTunless the backend contract explicitly supports safe replay via idempotency keys or equivalent semantics. - Use bounded exponential backoff with jitter; never tight loops.
- Retry transient failures such as network interruption,
Handle credentials and browser security boundaries
- If requests include cookies or other credentials, verify the frontend and backend agree on CORS behavior.
- Do not assume credentialed cross-origin requests work without explicit server configuration.
- For session-cookie flows, account for CSRF protections required by the backend architecture.
- Do not “fix” CORS problems by weakening browser policies in frontend code.
Test realistic failure modes
- Simulate rapid parameter changes, route transitions, and double-click submissions.
- Verify aborted or stale requests do not overwrite newer state.
- Verify retry behavior for transient failures and non-retry behavior for validation errors.
- Verify normalized errors drive correct UI messaging and logging.
Implementation Patterns
Pattern: component-scoped fetch with cancellation and stale-response guard
Use this when the request genuinely belongs to a single component and no framework-native loader or shared query cache is a better owner.
import { useEffect, useRef, useState } from 'react';
type User = { id: string; name: string };
type UiApiError = {
kind: 'http' | 'network' | 'timeout' | 'abort' | 'parse' | 'unknown';
status?: number;
code?: string;
title: string;
detail?: string;
retriable: boolean;
};
function normalizeError(error: unknown): UiApiError {
if (error instanceof DOMException && error.name === 'AbortError') {
return { kind: 'abort', title: 'Request cancelled', retriable: false };
}
if (error instanceof TypeError) {
return {
kind: 'network',
title: 'Network error',
detail: 'The request could not reach the server.',
retriable: true,
};
}
return {
kind: 'unknown',
title: 'Unexpected error',
retriable: false,
};
}
export function UserPanel({ userId }: { userId: string }) {
const requestSeq = useRef(0);
const [data, setData] = useState<User | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<UiApiError | null>(null);
useEffect(() => {
const controller = new AbortController();
const seq = ++requestSeq.current;
setLoading(true);
setError(null);
fetch(`/api/users/${userId}`, { signal: controller.signal })
.then(async (res) => {
if (!res.ok) {
let problem: any = null;
try {
problem = await res.json();
} catch {}
throw {
__ui_http_error: true,
status: res.status,
problem,
};
}
return res.json() as Promise<User>;
})
.then((next) => {
if (seq !== requestSeq.current) return;
setData(next);
})
.catch((err) => {
if (seq !== requestSeq.current) return;
if (err?.__ui_http_error) {
setError({
kind: 'http',
status: err.status,
code: err.problem?.type,
title: err.problem?.title ?? 'Request failed',
detail: err.problem?.detail,
retriable: err.status === 408 || err.status === 429 || err.status >= 500,
});
return;
}
const normalized = normalizeError(err);
if (normalized.kind !== 'abort') {
setError(normalized);
}
})
.finally(() => {
if (seq === requestSeq.current) {
setLoading(false);
}
});
return () => controller.abort();
}, [userId]);
if (loading && !data) return <p>Loading user…</p>;
if (error) return <p>{error.title}</p>;
if (!data) return <p>No user found.</p>;
return <div>{data.name}</div>;
}
Why this works:
- cleanup aborts the old request when the dependency changes or the component unmounts
- the sequence check blocks late arrivals from overwriting newer state
- aborts are treated as expected control flow, not user-facing failures
- HTTP errors are normalized separately from transport failures
Pattern: preserve previous data during refresh
If the user already has valid data on screen, prefer a non-destructive refresh model:
- keep current data visible
- show a secondary “refreshing” indicator
- replace data only when the newer request completes successfully
- show refresh errors without erasing previously rendered good state unless the domain requires hard invalidation
Pattern: mutation submission with duplicate-submit protection
For creates, updates, or deletes:
- disable or debounce repeated submits while one is active when duplicate execution would be harmful
- if retries for mutations are allowed, document the backend guarantee that makes replay safe
- keep server validation errors separate from generic transport failures so the UI can highlight actionable fields
Best Practices
Do:
- prefer router/framework data APIs when they already manage navigation-tied fetching
- use
AbortControllerfor browser fetch cancellation - pair cancellation with a stale-response guard such as a request sequence or identity check
- normalize errors into a stable frontend contract before they reach components
- keep old data visible during background refresh when possible
- retry only transient and safe-to-replay operations
- honor
Retry-Afterand back off with jitter - log or surface request identifiers when the backend provides them
- review credentialed requests for CORS and CSRF implications before shipping
Do not:
- treat every failure as “something went wrong” with no status or retry semantics
- auto-retry every
POST,PATCH, orDELETE - erase working UI data on every refresh attempt
- update state from whichever response resolves last without checking request relevance
- show aborted requests as user-visible errors by default
- work around CORS or CSRF problems by disabling security controls in development patterns that leak into production
- place fetches in effects when the framework already provides a better lifecycle-bound loading primitive
Examples
See these local support files for concrete operator material:
examples/request-response-example.mdfor end-to-end request/response handling with expected UI behaviorreferences/integration-patterns.mdfor retry, cancellation, and credentialed-request lookup tablesassets/schema-map.jsonfor a machine-readable mapping between raw backend/transport fields and normalized UI error fields
Example: stale search requests should not overwrite newer results
Input behavior:
1. User types "re"
2. Request A starts for /api/search?q=re
3. User types "react"
4. Request B starts for /api/search?q=react
5. Response B returns first
6. Response A returns later
Expected output behavior:
- UI shows results for "react"
- late response A is ignored or already aborted
- loading indicator reflects only the latest relevant request
Example: retry decision
Request: GET /api/reports/weekly
Failure: 503 Service Unavailable
Expected behavior:
- classify as transient HTTP failure
- retry with bounded backoff
- if server sends Retry-After, respect it
- if retries exhaust, show normalized retriable error
Example: do not blindly retry validation failure
Request: POST /api/users
Failure: 422 Unprocessable Content
Expected behavior:
- do not auto-retry
- parse validation payload if available
- map server feedback to field or form error state
- keep submit action available after user correction
Troubleshooting
Symptoms: Old search results or record details flash briefly after the user changes filters or route parameters.
Solution: Add both request cancellation and a stale-response guard. Aborting the previous request reduces wasted work; the sequence or identity check prevents late arrivals from updating state if the abort does not stop all downstream async handling.
Symptoms: The UI clears existing data every time a refresh starts, causing flicker or empty-state flashes.
Solution: Split initial-load state from background-refresh state. Keep previously successful data rendered while the next request is in flight, and show a non-blocking refresh indicator instead of resetting the screen.
Symptoms: Retries amplify production incidents or create duplicate records.
Solution: Re-check retry policy by HTTP method and operation semantics. Restrict automatic retries to transient failures and safe-to-replay requests. For unsafe mutations, require an explicit backend replay guarantee before enabling retries.
Symptoms: Users see generic errors even when the server returns structured failure details.
Solution: Normalize errors centrally and preserve fields such as status, title, detail, problem type, and request ID. Do not force each component to interpret raw backend payloads differently.
Symptoms: Requests with cookies fail only in browser environments, especially cross-origin.
Solution: Verify credential mode, allowed origin configuration, and server-side CORS headers together. If the flow relies on cookies, also confirm the expected CSRF defense. Frontend code alone cannot override a missing server CORS policy.
Symptoms: An aborted request still appears in logs or briefly flips error state.
Solution: Treat abort as expected control flow. Filter AbortError from user-facing error state and ensure post-abort asynchronous branches cannot commit stale state.
Additional Resources
- Integration patterns reference — open for retry safety, error taxonomy, cancellation matrix, and credentialed-request security checks.
- Request/response example — open when implementing a concrete fetch flow with expected UI outcomes.
- Schema map asset — open when standardizing error normalization fields or aligning frontend adapters across endpoints.
Related Skills
No additional related local skills were provided in the source context.