A production React frontend is often a client for several backend APIs. The difficult part is not writing one GET request. The difficult part is designing a consistent API layer that handles authentication, request cancellation, loading states, HTTP errors, business errors, empty results, stale requests, retries, timeouts, and user-friendly output. This chapter builds a complete product-list example.
REST API basics
A REST-style API exposes resources through HTTP. A product service might expose endpoints such as:
GET /api/products
GET /api/products/1001
POST /api/products
PUT /api/products/1001
PATCH /api/products/1001
DELETE /api/products/1001
Typical API response
{
"items": [
{
"id": 1001,
"name": "Laptop",
"price": 80000,
"stock": 12
}
],
"page": 1,
"pageSize": 20,
"total": 1
}
Fetch correctly
Fetch resolves the promise for HTTP error statuses, so the application must inspect the response.
async function getProducts() {
const response = await fetch("/api/products");
if (!response.ok) {
throw new Error(
`Request failed: ${response.status}`
);
}
return response.json();
}
Axios instance
A dedicated Axios instance keeps common configuration in one place.
import axios from "axios";
export const api = axios.create({
baseURL: "/api",
timeout: 10000,
headers: {
"Content-Type": "application/json"
}
});
API service layer
Components should normally ask for business data rather than constructing URLs and HTTP configuration themselves.
import { api } from "./api";
export async function getProducts(params) {
const response = await api.get("/products", {
params
});
return response.data;
}
export async function getProduct(id) {
const response = await api.get(`/products/${id}`);
return response.data;
}
export async function createProduct(product) {
const response = await api.post(
"/products",
product
);
return response.data;
}
Complete product page
function Products() {
const [products, setProducts] = useState([]);
const [status, setStatus] = useState("idle");
const [error, setError] = useState("");
useEffect(() => {
let active = true;
async function load() {
try {
setStatus("loading");
setError("");
const data = await getProducts();
if (active) {
setProducts(data.items);
setStatus("success");
}
} catch (error) {
if (active) {
setError("Unable to load products");
setStatus("error");
}
}
}
load();
return () => {
active = false;
};
}, []);
if (status === "loading") {
return <p>Loading products...</p>;
}
if (status === "error") {
return <p>{error}</p>;
}
if (status === "success" && products.length === 0) {
return <p>No products found.</p>;
}
return (
<ul>
{products.map(product => (
<li key={product.id}>
{product.name} - ₹{product.price}
</li>
))}
</ul>
);
}
Expected UI output
Initial screen:
Loading products...
After API success:
Products
-------------------------
Laptop ₹80,000
Mouse ₹2,000
Keyboard ₹4,000
If API returns zero items:
No products found.
If API fails:
Unable to load products
[Try Again]
Use a proper request state model
A simple boolean such as loading is often not enough. A request can be idle, loading, successful, failed, or successful with an empty result.
stateDiagram-v2
[*] --> Idle
Idle --> Loading
Loading --> Success
Loading --> Error
Success --> Loading
Error --> Loading
Success --> Empty
Success --> Data
Retry button
async function handleRetry() {
setStatus("loading");
setError("");
try {
const data = await getProducts();
setProducts(data.items);
setStatus("success");
} catch {
setStatus("error");
setError("Unable to load products");
}
}
if (status === "error") {
return (
<>
<p>{error}</p>
<button onClick={handleRetry}>
Try Again
</button>
</>
);
}
HTTP status handling
HTTP status codes should map to appropriate application behavior. A 401 may require authentication handling, a 403 may require a permission message, a 404 may mean a resource is missing, and 5xx responses generally represent server-side failures. The backend API contract remains the source of truth.
try {
await api.get("/admin/reports");
} catch (error) {
const status = error.response?.status;
if (status === 401) {
// start authentication flow
} else if (status === 403) {
// show permission message
} else if (status === 404) {
// resource not found
} else if (status >= 500) {
// server problem
}
}
Centralized response interceptor
Interceptors can centralize selected cross-cutting behavior. Keep them small and predictable.
api.interceptors.response.use(
response => response,
error => {
if (error.response?.status === 401) {
window.dispatchEvent(
new Event("session-expired")
);
}
return Promise.reject(error);
}
);
Authentication headers
The exact authentication strategy depends on the application architecture. If a token is used in an Authorization header, the API client can add it consistently. If the application uses a secure server-managed session, the browser may instead send credentials associated with that session.
api.interceptors.request.use(config => {
const token = getAccessToken();
if (token) {
config.headers.Authorization =
`Bearer ${token}`;
}
return config;
});
Do not blindly retry every failure
Retrying a request can be appropriate for some transient failures, but not for every error. A 400 validation error will not become valid simply because the client sends it again. Automatic retries should be limited and should normally use backoff.
// Conceptual retry sequence:
//
// attempt 1 -> failure
// wait
// attempt 2 -> failure
// wait longer
// attempt 3 -> failure
// stop and show error
Timeout handling
export const api = axios.create({
baseURL: "/api",
timeout: 10000
});A timeout gives the UI a defined failure path rather than waiting indefinitely for an operation.
Request cancellation
Cancellation is particularly important for search screens. If the user types lap, then laptop, the older request may finish after the newer one. Cancelling the old request prevents stale work from winning.
const controller = new AbortController();
fetch("/api/products?search=laptop", {
signal: controller.signal
});
// When a newer request replaces this one:
controller.abort();
Search race condition
sequenceDiagram
participant UI
participant API
UI->>API: Search lap
UI->>API: Search laptop
API-->>UI: laptop result
API-->>UI: lap result
Note over UI: Ignore or cancel stale result
Pagination
Large collections should not be loaded in one response. Pagination keeps response sizes manageable and allows the UI to request only the required page.
const data = await getProducts({
page: 2,
pageSize: 20,
search: "laptop",
sort: "price_desc"
});
Debounced search
For search-as-you-type, debounce user input so that the application does not send a request for every keystroke. Debouncing and cancellation solve different problems: debounce reduces request frequency, while cancellation prevents stale requests from winning.
Business errors vs HTTP errors
An HTTP 200 response can still contain a business result that the UI needs to handle explicitly, while an HTTP 400 can contain structured field validation. The API contract should define the distinction.
{
"success": false,
"code": "INSUFFICIENT_STOCK",
"message": "Only 2 items are available"
}
Complete create-product flow
sequenceDiagram
participant UI
participant Service
participant API
participant DB
UI->>Service: createProduct(data)
Service->>API: POST /products
API->>DB: Save product
DB-->>API: Product
API-->>Service: 201 Created
Service-->>UI: Product
UI->>UI: Show success
Error output examples
401:
Your session has expired.
[Sign in again]
403:
You do not have permission
to view this report.
404:
Product was not found.
409:
Product already exists.
422:
Please correct the highlighted fields.
500:
Something went wrong.
[Try Again]
Testing the API layer
Because the HTTP logic is isolated in service functions, tests can mock the API client and verify how the application behaves for success, validation errors, unauthorized responses, timeouts, and server failures.
Common production mistakes
- Calling APIs directly from dozens of components.
- Not checking fetch response.ok.
- Using one generic message for every HTTP failure.
- Ignoring empty results.
- Not cancelling stale search requests.
- Retrying permanent failures.
- Putting authentication secrets in unsafe client-side locations.
- Loading enormous collections without pagination.
Interview questions
- Fetch vs Axios?
- Why does fetch not throw for HTTP 500?
- How would you design an API service layer?
- How do you handle loading, success, empty, and error states?
- How do you prevent stale search results?
- When should an API call be retried?
- How do you handle 401 and 403 differently?
- Why is pagination important?
Key takeaways
- Build a reusable API layer instead of scattering HTTP calls across components.
- Model request states explicitly.
- Handle HTTP and business errors separately.
- Use cancellation and debouncing for search-heavy screens.
- Keep security enforcement on the backend.