API Integration with a .NET Backend
One typed client, three screen states, and a check that the server really said yes
Guess first
Pick an answer. You will see if you were right at the end of the lesson.
A fetch call gets a 500 response from the API. What does the returned promise do?
Real-world analogy
What is it?
API integration is the layer between your React screens and the HTTP API. A good one has a single typed module. It builds requests, checks the status, turns error bodies into messages, and returns typed data. Screens call it from effects or a data library and render loading, error and data. The goal is that a failed request becomes a clear message, not a crash or a silent wrong value.
Real-world relevance
A product list page calls the .NET API for page two. The server returns 500 for a moment during a deploy. Without an ok check, the page tries to map over an error object and crashes. With the check, the user sees a retry message. On the form side, a 400 ProblemDetails has an errors object. The page can show "Name is required" under the right field. A generic failure message would hide that.
Key points
- One typed API client — Keep the URLs, headers and error handling in one module with functions like getProducts(page). Components call functions, not fetch, so a change happens in one place.
- Types mirror the DTOs — Write a TypeScript type for each response shape the .NET API returns. You can generate them from the OpenAPI document, so a change on the server shows up as a compile error.
- Three states: load, error, data — Every request is loading, failed or done. Render each of the three states, or the screen shows a blank page or old data.
- fetch does not throw on 500 — fetch rejects on a network failure or an abort, not on an error status. A 400 or 500 still resolves, so check response.ok and throw yourself.
- Read ProblemDetails errors — ASP.NET Core can return a JSON body with title, status and, for validation, an errors object by field. Parse it and show each message next to its input.
- Abort on unmount — Create an AbortController in the effect and call abort() in the cleanup. This stops work for a screen the user already left.
- Fast requests race — If the user changes the page quickly, the older response can arrive last and overwrite the newer one. Aborting the old request, or ignoring its result, fixes it.
- Base URL from environment — Read the API address from a build setting, such as import.meta.env.VITE_API_URL in Vite. The same code then runs against local, staging and production.
- CORS is fixed on the server — A CORS error means the API did not allow your origin in its response headers. Add the origin to the ASP.NET Core CORS policy, since nothing in the React code can bypass it.
Quick check
Answer from memory — scrolling up first makes it stick less.
The browser console shows a CORS error when your React app calls the API. Where is the fix?
Why call abort() in an effect cleanup that makes a request?
Code example
// api/products.ts (a fragment: two files shown one after the other)
export type Product = { id: number; name: string; price: number };
export type Page<T> = { items: T[]; totalCount: number };
const baseUrl = import.meta.env.VITE_API_URL as string;
export async function getProducts(page: number, signal?: AbortSignal): Promise<Page<Product>> {
const response = await fetch(baseUrl + '/api/products?page=' + page, { signal });
if (!response.ok) {
const problem = await response.json().catch(() => null);
throw new Error(problem?.title ?? 'Request failed with ' + response.status);
}
return response.json() as Promise<Page<Product>>;
}
// ProductList.tsx (assume useState and useEffect are imported)
export function ProductList({ page }: { page: number }) {
const [data, setData] = useState<Page<Product> | null>(null);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
const controller = new AbortController();
setData(null); setError(null);
getProducts(page, controller.signal)
.then(setData)
.catch(e => {
if (e.name !== 'AbortError') setError(e.message);
});
return () => controller.abort();
}, [page]);
if (error) return <p role="alert">{error}</p>;
if (!data) return <p>Loading...</p>;
return <ul>{data.items.map(p => <li key={p.id}>{p.name}</li>)}</ul>;
}Line-by-line walkthrough
- 1. This is a fragment of two files. The first block is the API module: types for a product and a page of products.
- 2. baseUrl comes from an environment variable, so the same code works in each environment.
- 3. getProducts checks response.ok. On failure it reads the ProblemDetails title if there is one and throws an Error.
- 4. On success it returns the JSON body typed as a Page of Product.
- 5. ProductList starts with no data and no error. Its effect creates an AbortController and calls the client with the signal.
- 6. The cleanup aborts the request when page changes or the component unmounts, and AbortError is ignored on purpose.
- 7. The render handles three states in order: error, loading, then the data.
Spot the bug
type Product = { id: number; name: string };
async function loadProducts(): Promise<Product[]> {
const response = await fetch('/api/products');
return response.json();
}
loadProducts()
.then(products => console.log(products.length))
.catch(() => console.log('failed'));Need a hint?
Show answer
Explain like I'm 5
Fun fact
Hands-on challenge
More resources
- Using the Fetch API (MDN)
- Handle errors in ASP.NET Core APIs (Microsoft Learn)
- Free Full-Stack Playbook (React and TypeScript lessons) (Teamz Lab)
Lock it in