API Integration
Before you start
You need: routing (Article 05) and an API — Track 10, or a mock.
Time: about 50 minutes, plus the practice.
Learning objective
Call a REST API handling every outcome — loading, success, empty, error and cancellation — and diagnose a failed request from the Network tab.
Topics
- A typed API client
- The four states
- Cancellation and races
- Debounced search
- Mutations
- Status codes
- Fetch versus Axios
- TanStack Query
- CORS and environments
A typed API client
// api/client.ts
export class ApiError extends Error {
constructor(
public readonly status: number,
public readonly body: unknown
) {
super(`Request failed with status ${status}`);
this.name = 'ApiError';
}
}
const baseUrl = import.meta.env.VITE_API_URL as string;
export async function apiRequest<T>(
path: string,
options: RequestInit = {}
): Promise<T> {
const token = getToken();
const response = await fetch(`${baseUrl}${path}`, {
...options,
headers: {
Accept: 'application/json',
...(options.body ? { 'Content-Type': 'application/json' } : {}),
...(token ? { Authorization: `Bearer ${token}` } : {}),
...options.headers
}
});
if (response.status === 401) {
clearToken();
window.location.href = '/login';
throw new ApiError(401, 'Session expired');
}
if (!response.ok) {
let body: unknown;
try {
body = await response.json();
} catch {
body = await response.text();
}
throw new ApiError(response.status, body);
}
if (response.status === 204) {
return null as T;
}
return (await response.json()) as T;
}
fetch does not reject on 404 or 500. It rejects only on a network-level failure. A 404 resolves normally, and calling .json() on an HTML error page throws Unexpected token < in JSON at position 0 — which sends people looking for a JSON bug that does not exist.
response.ok must be checked on every call, which is why this wrapper exists.
Calling .json() on a 204 throws — the body is empty, and that is a valid response for a successful DELETE.
// api/students.ts
export const studentApi = {
search(query: StudentQuery, signal?: AbortSignal): Promise<PagedResult<Student>> {
const params = new URLSearchParams({
page: String(query.page),
pageSize: String(query.pageSize)
});
if (query.term) params.set('term', query.term);
if (query.className) params.set('className', query.className);
return apiRequest<PagedResult<Student>>(`/api/students?${params}`, { signal });
},
getByPublicId(publicId: string, signal?: AbortSignal): Promise<Student> {
return apiRequest<Student>(`/api/students/${publicId}`, { signal });
},
create(request: StudentCreateRequest): Promise<Student> {
return apiRequest<Student>('/api/students', {
method: 'POST',
body: JSON.stringify(request)
});
},
update(publicId: string, request: StudentUpdateRequest): Promise<void> {
return apiRequest<void>(`/api/students/${publicId}`, {
method: 'PUT',
body: JSON.stringify(request)
});
},
remove(publicId: string): Promise<void> {
return apiRequest<void>(`/api/students/${publicId}`, { method: 'DELETE' });
}
};
schoolId appears nowhere. The API reads it from the JWT claim. A client-supplied tenant id is a request to choose whose data to read.
URLSearchParams encodes correctly — a search for 10th & A works. String concatenation does not.
apiRequest<T> is a promise to the compiler, not a check. Nothing validates the response shape; a backend rename produces undefined at the point of use. For data that matters, validate at the boundary with a schema library.
The four states
Every request has four outcomes. Most student projects handle one.
type ListState =
| { status: 'loading' }
| { status: 'success'; result: PagedResult<Student> }
| { status: 'empty'; term: string }
| { status: 'error'; message: string };
export function useStudents(term: string, page: number) {
const [state, setState] = useState<ListState>({ status: 'loading' });
const [reloadToken, setReloadToken] = useState(0);
useEffect(() => {
const controller = new AbortController();
setState({ status: 'loading' });
studentApi.search({ term, page, pageSize: 20 }, controller.signal)
.then(result => {
setState(result.items.length === 0
? { status: 'empty', term }
: { status: 'success', result });
})
.catch((err: unknown) => {
if (err instanceof DOMException && err.name === 'AbortError') {
return; // superseded, not a failure
}
setState({
status: 'error',
message: err instanceof ApiError && err.status >= 500
? 'The server is not responding. Please try again shortly.'
: 'Could not load students.'
});
});
return () => controller.abort();
}, [term, page, reloadToken]);
const reload = useCallback(() => setReloadToken(prev => prev + 1), []);
return { state, reload };
}
export function StudentListPage() {
const [searchParams, setSearchParams] = useSearchParams();
const term = searchParams.get('term') ?? '';
const page = Number(searchParams.get('page') ?? '1');
const debouncedTerm = useDebounce(term, 300);
const { state, reload } = useStudents(debouncedTerm, page);
return (
<>
<SearchBox value={term} onChange={updateSearch} />
<p role="status" aria-live="polite" className="visually-hidden">
{state.status === 'loading' ? 'Loading students' : ''}
</p>
{state.status === 'loading' && <p className="muted">Loading students…</p>}
{state.status === 'empty' && (
<p className="muted">No students match “{state.term}”.</p>
)}
{state.status === 'error' && (
<div role="alert">
<p className="error">{state.message}</p>
<button type="button" onClick={reload}>Try again</button>
</div>
)}
{state.status === 'success' && <StudentTable result={state.result} />}
</>
);
}
A discriminated union means TypeScript enforces that every state is handled, and state.term is only reachable in the empty branch.
Distinguish empty from error. "No students match your search" and "Could not load students" are different messages with different next actions. A blank screen for both is indistinguishable from a bug.
aria-live="polite" announces state changes to a screen reader. A visual-only spinner tells them nothing.
The error state has a retry button — an error with no way forward is a dead end.
Cancellation and races
return () => controller.abort();
Without abort, two problems appear. Navigating quickly from student A to student B leaves A's slower response to arrive last and overwrite B — a visible race that looks like a caching bug. And setting state after unmount is wasted work.
Reproduce it: throttle to Slow 3G and click quickly between two students. The wrong one renders.
AbortError is not a failure. Treating it as one shows an error for a request you cancelled yourself.
Debounced search
export function useDebounce<T>(value: T, delay = 300): T {
const [debounced, setDebounced] = useState(value);
useEffect(() => {
const id = window.setTimeout(() => setDebounced(value), delay);
return () => window.clearTimeout(id);
}, [value, delay]);
return debounced;
}
Typing "Ravi" fires one request instead of four. The cleanup clears the previous timer, so only the final value after a pause is committed.
Debounce plus AbortController covers both problems: fewer requests, and no out-of-order results among the ones that do fire.
Mutations
export function useCreateStudent() {
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
const [fieldErrors, setFieldErrors] = useState<Record<string, string>>({});
async function create(request: StudentCreateRequest): Promise<Student | null> {
setSaving(true);
setError(null);
setFieldErrors({});
try {
return await studentApi.create(request);
} catch (err) {
if (err instanceof ApiError && err.status === 400) {
const body = err.body as { errors?: Record<string, string[]> };
if (body.errors) {
setFieldErrors(mapServerErrors(body.errors));
return null;
}
}
setError(err instanceof ApiError && err.status === 409
? 'This roll number is already in use.'
: 'Could not save. Please try again.');
return null;
} finally {
setSaving(false);
}
}
return { create, saving, error, fieldErrors };
}
finally clears saving on both paths. Clearing it only on success leaves the button disabled forever after an error — the "page froze" report.
Do not cancel a mutation with AbortController. A cancelled POST may still have reached the server, so the client does not know whether the student was created. Disable the button instead.
Status codes
err.status | Meaning | Show the user |
|---|---|---|
0 (fetch throws) | Network failure or CORS block | "Cannot reach the server" |
| 400 | Validation failed | Field-level messages from errors |
| 401 | Not authenticated | Redirect to login |
| 403 | Not permitted | "You do not have permission" |
| 404 | Not found | "This student no longer exists" |
| 409 | Conflict | The specific conflict |
| 415 | Wrong content type | A bug in your code |
| 500+ | Server failure | "Please try again shortly" |
401 versus 403. 401 means authenticate; 403 means authenticated and not permitted, so logging in again changes nothing.
A network failure or CORS block makes fetch reject rather than resolve with a status — so it arrives as a TypeError, not an ApiError:
catch (err) {
if (err instanceof TypeError) {
setError('Cannot reach the server. Check your connection.');
return;
}
}
ASP.NET Core returns a ValidationProblemDetails body on 400:
{
"status": 400,
"traceId": "00-8a3c…-01",
"errors": {
"RollNumber": ["Format: NCA-2024-0012"],
"ParentPhone": ["Enter a valid 10-digit mobile number."]
}
}
function mapServerErrors(errors: Record<string, string[]>): Record<string, string> {
const mapped: Record<string, string> = {};
for (const [field, messages] of Object.entries(errors)) {
const key = field.charAt(0).toLowerCase() + field.slice(1);
if (messages[0]) mapped[key] = messages[0];
}
return mapped;
}
The PascalCase-to-camelCase conversion is what makes the keys match your state. Show the traceId on a 500 — the user quotes it and someone finds the request in the logs.
Fetch versus Axios
npm install axios
const api = axios.create({
baseURL: import.meta.env.VITE_API_URL,
timeout: 10000
});
api.interceptors.request.use(config => {
const token = getToken();
if (token) config.headers.Authorization = `Bearer ${token}`;
return config;
});
api.interceptors.response.use(
response => response,
error => {
if (error.response?.status === 401) {
clearToken();
window.location.href = '/login';
}
return Promise.reject(error);
}
);
| Fetch | Axios | |
|---|---|---|
| Built in | Yes | No — a dependency |
| Throws on 4xx/5xx | No | Yes |
| JSON parsing | Manual | Automatic |
| Interceptors | Write your own | Built in |
| Timeouts | AbortSignal.timeout() | Built in |
| Upload progress | Awkward | Built in |
Both are fine. Axios removes the response.ok check that people forget, and interceptors keep the token in one place. Fetch adds no dependency and is enough with the wrapper above.
Choose one and use it everywhere. Two HTTP clients in one project means two error shapes and two places to fix anything.
TanStack Query
For anything beyond a few endpoints, a data-fetching library removes most of this article's code.
npm install @tanstack/react-query
const queryClient = new QueryClient({
defaultOptions: { queries: { staleTime: 30_000, retry: 1 } }
});
<QueryClientProvider client={queryClient}>
<RouterProvider router={router} />
</QueryClientProvider>
export function useStudents(term: string, page: number) {
return useQuery({
queryKey: ['students', term, page],
queryFn: ({ signal }) => studentApi.search({ term, page, pageSize: 20 }, signal),
placeholderData: keepPreviousData
});
}
export function StudentListPage() {
const { data, isPending, isError, error, refetch } = useStudents(debouncedTerm, page);
if (isPending) return <p className="muted">Loading students…</p>;
if (isError) return <ErrorMessage message={getMessage(error)} onRetry={refetch} />;
if (data.items.length === 0) return <p className="muted">No students match this search.</p>;
return <StudentTable result={data} />;
}
export function useCreateStudent() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (request: StudentCreateRequest) => studentApi.create(request),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['students'] });
}
});
}
It handles caching, deduplication, cancellation, retries, background refetching and invalidation — every problem in this article, and several it does not cover.
invalidateQueries after a mutation is the feature that matters most: creating a student automatically refreshes every list showing students, with no manual state synchronisation.
keepPreviousData shows the previous page while the next loads, instead of a spinner between pages.
Learn the manual version first. Knowing what the library does is what lets you debug it when a query does not refetch or a cache key is wrong.
Environments
# .env.development
VITE_API_URL=https://localhost:7099
# .env.production
VITE_API_URL=https://api.nexcoding.in
const baseUrl = import.meta.env.VITE_API_URL as string;
Only variables prefixed VITE_ are exposed. That prefix is a deliberate safeguard — everything else stays out of the bundle.
Exposed means shipped to the browser. npm run build writes the value into a file anyone can read. URLs and feature flags belong here; never an API key or a secret. There is no such thing as a client-side secret.
A dev proxy avoids CORS locally:
// vite.config.ts
export default defineConfig({
server: {
proxy: {
'/api': { target: 'https://localhost:7099', changeOrigin: true, secure: false }
}
}
});
This only works in npm run dev. Production needs real CORS — which is why a proxy sometimes hides a problem until deployment.
CORS
Access to fetch at 'https://api.nexcoding.in/api/students'
from origin 'https://portal.nexcoding.in' has been blocked by CORS policy.
No React change fixes this. No fetch option, no header, no library setting.
The request usually reached the server and succeeded — the server returned 200, and the browser then refused to hand the response to your code. That is why the same call works in Postman, which is not a browser.
// The fix is in Program.cs
builder.Services.AddCors(options =>
options.AddPolicy("SchoolPortal", policy =>
policy.WithOrigins("https://portal.nexcoding.in")
.AllowAnyHeader()
.AllowAnyMethod()));
app.UseRouting();
app.UseCors("SchoolPortal");
app.UseAuthentication();
An OPTIONS request returning 404 or 405 means CORS middleware is not wired up. One returning 401 means UseCors is after UseAuthentication — the preflight carries no token.
Diagnosing a failed call
Open the Network tab, filter to Fetch/XHR, then reproduce.
| What you see | Meaning |
|---|---|
| No request | The code never ran |
(failed) or (canceled) | Network failure, CORS block, or aborted |
| 401 | Token missing or expired |
| 403 | Wrong role |
| 415 | Missing Content-Type — a bug in the caller |
| 400 | Read the errors object for the fields |
| 500 | Server-side; the browser cannot tell you why |
| 200 but nothing renders | The response arrived; the rendering code is wrong |
Copy as cURL, replay in Postman. Works there but fails in the browser → a frontend problem: CORS, a header, or the token. Fails in both → a backend problem.
A copied cURL contains your live Authorization header. Redact it before pasting it into a ticket.
Errors you will hit
| What you see | Cause | Fix |
|---|---|---|
Unexpected token < in JSON | The response was HTML, not JSON | Check the status code |
fetch does not throw on 404 | It rejects only on network failure | Check response.ok |
| CORS error in the browser, fine in Postman | Browser-enforced, server-configured | Fix on the API |
| Results flash the wrong data | A stale response arrived last | Abort with AbortController |
| Effect fires twice in development | React Strict Mode double-invokes | Expected; make effects idempotent |
Fields are undefined | Casing mismatch with the API | Match exactly |
Strict Mode running effects twice in development is deliberate. It surfaces effects that are not safe to run twice.
Common mistakes
- Not checking
response.ok - Calling
.json()on a 204 - Missing
Content-Type: application/json, causing 415 - Setting
Content-TypewithFormData - No
AbortController, producing out-of-order results - Treating
AbortErroras a failure - Cancelling a mutation
- No debounce on a live search
- Only handling success — no loading, empty or error state
- Clearing
savingonly on success - Trusting
apiRequest<T>()as validation - Sending
schoolIdfrom the client - Confusing 401 and 403
- Trying to fix CORS in React
- A secret in a
VITE_variable - Two HTTP clients in one project
Practice
The course exercises are render API data and handle loading and error states.
- Build the typed client with
ApiError, 401 handling and 204 handling. - Call an endpoint returning 404 without checking
ok. Record the error from.json(). - Call a DELETE returning 204 and call
.json(). Record the error, then handle it. - POST without
Content-Typeand confirm the 415. - Implement all four states with a discriminated union. Force each.
- Stop the API and trigger a request. Confirm the
TypeErrorand write the right message. - Remove the
AbortController. Throttle to Slow 3G, click quickly between two students, and confirm the wrong one renders. - Add it back and confirm the race is gone.
- Show an error for
AbortError, then exclude it. - Build the search with no debounce. Type quickly and count the requests. Add
useDebounceand compare. - Map a 400
errorsobject to form fields. - Force a 500 and show the
traceId. Find the matching entry in the API log. - Clear
savingonly in the success path, force an error, and confirm the button stays disabled. - Convert one hook to TanStack Query. Compare the line counts.
- Add
invalidateQueriesafter a create and confirm the list refreshes with no manual state update. - Configure the Vite proxy and call the API without CORS. Then
npm run build && npm run previewand confirm the CORS error appears.
Exercises 7 and 16 correspond to a visible race and a problem that only appears after deployment.
You can now
- Call an API handling loading, success, empty and error
- Check
response.okfor every request - Cancel stale requests with
AbortController - Recognise a CORS error and say where it is fixed
- Say why Strict Mode runs effects twice
Review questions
- Why does
fetchnot reject on a 500, and what must you do about it? - What breaks without an
AbortControlleron a detail fetch? - Why should a mutation not be cancelled?
- Why can CORS not be fixed in React?