Welcome back to the React Mastery Series!
In Day 32, we explored State Management Architecture and learned how to decide between:
useStateuseReducer- Context API
- Redux Toolkit
- TanStack Query
Today, we're going to look at another area that becomes increasingly important as a React application grows:
API Architecture
In a small application, you might write:
const response = await fetch("/api/users");
directly inside a component.
That works.
But imagine an enterprise application with:
- 100+ APIs
- Multiple backend services
- Authentication
- Token refresh
- Standardized error handling
- Request logging
- Retry mechanisms
- Multiple environments
Putting API logic directly inside components quickly becomes difficult to maintain.
We need an architecture.
The Problem with API Calls Inside Components
Consider:
function Users() {
useEffect(() => {
fetch("/api/users")
.then((response) => response.json())
.then((data) => setUsers(data));
}, []);
return <UserList />;
}
It looks simple.
But now imagine every component contains:
API URL
Authentication
Headers
Error Handling
Retry Logic
Response Transformation
Logging
The application becomes tightly coupled to the backend.
Better Architecture
A scalable architecture separates responsibilities:
React Component
│
▼
Custom Hook
│
▼
Service Layer
│
▼
API Client
│
▼
Backend
Each layer has a specific responsibility.
Layer 1 – Component
The component should focus on UI.
function UserList() {
const {
users,
isLoading,
error,
} = useUsers();
if (isLoading) {
return <p>Loading...</p>;
}
if (error) {
return <p>Unable to load users.</p>;
}
return (
<ul>
{users.map((user) => (
<li key={user.id}>
{user.name}
</li>
))}
</ul>
);
}
The component doesn't know:
- Which URL is called
- How authentication works
- Which HTTP client is used
That's intentional.
Layer 2 – Custom Hook
The hook manages React-specific behavior.
function useUsers() {
const [users, setUsers] = useState<User[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<Error | null>(null);
useEffect(() => {
getUsers()
.then(setUsers)
.catch(setError)
.finally(() => setIsLoading(false));
}, []);
return {
users,
isLoading,
error,
};
}
The hook connects the UI to the service layer.
Layer 3 – Service Layer
The service knows how to communicate with the backend.
export async function getUsers() {
const response = await api.get<User[]>("/users");
return response.data;
}
The service doesn't care which component called it.
It simply provides an API operation.
Layer 4 – API Client
The API client contains common HTTP configuration.
import axios from "axios";
export const api = axios.create({
baseURL: import.meta.env.VITE_API_URL,
timeout: 10000,
});
Now all services use the same client.
Why Create an Axios Instance?
Instead of:
axios.get(...);
axios.post(...);
axios.put(...);
everywhere, use:
api.get(...);
api.post(...);
api.put(...);
This gives us a central location for:
- Base URL
- Headers
- Authentication
- Interceptors
- Timeout
- Error handling
Axios vs Fetch
Both are valid choices.
| Feature | Fetch | Axios |
|---|---|---|
| Built into browser | ✅ | ❌ |
| Automatic JSON transformation | ❌ | ✅ |
| Interceptors | ❌ | ✅ |
| Request cancellation | ✅ | ✅ |
| Simple API | ✅ | ✅ |
| Automatic status handling | Manual | More convenient |
There is no universal winner.
Choose based on your team's requirements and existing architecture.
Request Interceptors
Suppose every authenticated request needs:
Authorization: Bearer <token>
Instead of adding it manually:
api.get("/users", {
headers: {
Authorization: `Bearer ${token}`,
},
});
we can use an interceptor.
api.interceptors.request.use(
(config) => {
const token = getAccessToken();
if (token) {
config.headers.Authorization =
`Bearer ${token}`;
}
return config;
}
);
Now the header can be added centrally.
Response Interceptors
Response interceptors allow centralized processing of responses.
api.interceptors.response.use(
(response) => response,
(error) => {
console.error(
"API Error:",
error
);
return Promise.reject(error);
}
);
This gives us one place to implement common behavior.
Handling HTTP Status Codes
A typical enterprise application may handle:
200 → Success
400 → Bad Request
401 → Unauthorized
403 → Forbidden
404 → Not Found
409 → Conflict
422 → Validation Error
500 → Server Error
For example:
api.interceptors.response.use(
(response) => response,
(error) => {
switch (error.response?.status) {
case 401:
handleUnauthorized();
break;
case 403:
handleForbidden();
break;
case 500:
handleServerError();
break;
}
return Promise.reject(error);
}
);
401 vs 403
This distinction is important.
401 – Unauthorized
Usually means:
The request doesn't have valid authentication credentials.
Example:
Access Token Expired
The application may attempt a token refresh.
403 – Forbidden
Usually means:
The user is authenticated but doesn't have permission.
Example:
Customer attempts Admin API
The backend rejects the operation.
Token Refresh Architecture
A common enterprise flow looks like:
Request
│
▼
API
│
▼
401
│
▼
Refresh Token
│
▼
New Access Token
│
▼
Retry Original Request
Conceptually:
api.interceptors.response.use(
(response) => response,
async (error) => {
if (
error.response?.status === 401
) {
// Refresh token
// Retry original request
}
return Promise.reject(error);
}
);
In production systems, token refresh needs careful handling to avoid multiple simultaneous refresh requests.
The Multiple Request Problem
Imagine five API requests happen simultaneously:
Request A → 401
Request B → 401
Request C → 401
Request D → 401
Request E → 401
If every request independently refreshes the token:
5 Requests
↓
5 Refresh Requests
This can create race conditions.
A better architecture coordinates refresh operations so that only one refresh is active while other failed requests wait for the new token.
Request Cancellation
Users may navigate away before an API request completes.
For example:
Search Page
User types:
"react"
↓
API Request
↓
User navigates away
↓
Request no longer needed
Canceling unnecessary requests can reduce resource usage.
Modern fetch uses AbortController.
const controller =
new AbortController();
fetch("/api/users", {
signal: controller.signal,
});
controller.abort();
Axios also supports cancellation using AbortSignal.
Search Request Example
Without cancellation:
r
re
rea
reac
react
could trigger multiple requests.
Instead, combine:
- Debouncing
- Request cancellation
- Server-side filtering
Example:
const controller =
new AbortController();
await api.get("/users", {
params: {
search: query,
},
signal: controller.signal,
});
Retry Strategy
Not every failed request should be retried.
For example:
GET /accounts
might be safely retryable.
But automatically retrying:
POST /payments
can be dangerous if the operation is not idempotent.
Imagine:
Payment Request
↓
Timeout
↓
Client Retries
↓
Payment Processed Twice
This is a serious financial problem.
Retry strategies must consider the semantics of each operation.
Idempotency
An operation is idempotent when repeating it produces the same intended result.
For example:
GET /accounts
is generally safe to repeat.
But:
POST /payments
may create multiple transactions if the server doesn't provide idempotency protection.
Enterprise payment systems often use an idempotency key.
Example:
Idempotency-Key: 8f2c1d7a
The backend can use this key to prevent accidental duplicate processing.
API Types with TypeScript
Don't leave API responses untyped.
Define interfaces.
export interface User {
id: number;
name: string;
email: string;
}
Then:
const response =
await api.get<User[]>("/users");
Now TypeScript understands the response.
Request Types
Define request models too.
export interface CreateUserRequest {
name: string;
email: string;
role: string;
}
Service:
export async function createUser(
payload: CreateUserRequest
) {
const response =
await api.post<User>(
"/users",
payload
);
return response.data;
}
This provides compile-time safety.
Response Transformation
Backend responses aren't always shaped exactly the way the UI needs.
For example:
{
"first_name": "Siva",
"last_name": "Samanthapudi"
}
The UI might prefer:
{
fullName: "Siva Samanthapudi"
}
Transform the data in the service or domain layer rather than scattering transformation logic across components.
Multiple Backend Services
Large applications may communicate with several services:
React Application
│
├── Customer API
├── Accounts API
├── Payments API
├── Cards API
└── Notifications API
You can create dedicated clients when their configuration differs.
export const customerApi =
axios.create({
baseURL:
import.meta.env.VITE_CUSTOMER_API,
});
export const paymentApi =
axios.create({
baseURL:
import.meta.env.VITE_PAYMENT_API,
});
This is particularly useful in enterprise environments.
Suggested API Folder Structure
src
├── api
│ ├── clients
│ │ ├── customerApi.ts
│ │ └── paymentApi.ts
│ │
│ ├── interceptors
│ │ ├── requestInterceptor.ts
│ │ └── responseInterceptor.ts
│ │
│ └── index.ts
│
├── features
│ ├── accounts
│ │ └── services
│ │ └── accountService.ts
│ │
│ └── payments
│ └── services
│ └── paymentService.ts
This keeps infrastructure separate from business features.
API Layer + TanStack Query
If you're using TanStack Query, the architecture becomes:
Component
│
▼
useAccounts()
│
▼
TanStack Query
│
▼
accountService
│
▼
API Client
│
▼
Backend
Each layer has a clear responsibility.
Example
Service:
export async function getAccounts() {
const response =
await api.get<Account[]>("/accounts");
return response.data;
}
Hook:
export function useAccounts() {
return useQuery({
queryKey: ["accounts"],
queryFn: getAccounts,
});
}
Component:
function AccountList() {
const {
data: accounts,
isPending,
isError,
} = useAccounts();
if (isPending) {
return <p>Loading...</p>;
}
if (isError) {
return (
<p>
Unable to load accounts.
</p>
);
}
return (
<ul>
{accounts.map((account) => (
<li key={account.id}>
{account.name}
</li>
))}
</ul>
);
}
This is a clean separation of concerns.
Centralized Error Model
Instead of exposing raw Axios errors everywhere, create a standard application error.
export type ApiError = {
status: number;
message: string;
code?: string;
};
Then transform backend errors into a consistent format.
Backend Errors
↓
API Client
↓
Normalized Error
↓
Application
↓
User-Friendly Message
This is especially valuable when multiple backend services return different error formats.
Common Mistakes
1. Calling APIs Directly From Every Component
This creates duplication and tight coupling.
Use services or dedicated data-access hooks.
2. Hardcoding URLs
Avoid:
fetch("https://production-api.example.com/users");
Use environment-based configuration.
3. Repeating Authentication Headers
Centralize authentication behavior where appropriate.
4. Retrying Every Failed Request
Be particularly careful with mutations such as payments and order creation.
5. Ignoring Cancellation
Long-running requests can become unnecessary when users navigate away.
6. Exposing Backend Error Messages Directly
Backend messages may contain technical information unsuitable for end users.
Normalize errors and provide user-friendly messages.
Production-Ready API Architecture
A mature React application can follow:
React UI
│
▼
Custom Hooks
│
▼
TanStack Query
│
▼
Service Layer
│
▼
API Client
│
┌──────────┴──────────┐
│ │
Interceptors Error Handling
│ │
└──────────┬──────────┘
│
▼
Backend APIs
This structure keeps responsibilities clear.
Senior Engineer Checklist
When designing an API layer, ask:
- Where should API calls live?
- How are authentication headers added?
- How are 401 responses handled?
- How are 403 responses handled?
- How are errors normalized?
- Which requests can safely be retried?
- How are requests cancelled?
- How are API responses typed?
- How are multiple backend services handled?
- Where should server-state caching happen?
These questions are more important than simply knowing Axios syntax.
Key Takeaways
Today, we learned:
✅ Keep API communication outside presentation components.
✅ Use a service layer to isolate backend communication.
✅ Centralize common HTTP configuration.
✅ Interceptors can handle cross-cutting request and response behavior.
✅ 401 and 403 represent different security scenarios.
✅ Token refresh requires careful concurrency handling.
✅ Don't blindly retry non-idempotent operations.
✅ Use TypeScript models for API contracts.
✅ Request cancellation improves application efficiency.
✅ TanStack Query can sit above the service layer to manage server state.
Coming Next 🚀
In Day 34, we'll take API architecture one step further:
React Forms at Scale – React Hook Form, Validation, Dynamic Forms & Enterprise Form Architecture
We'll explore:
- Controlled vs uncontrolled forms
- React Hook Form
- Form validation
- Zod integration
- Dynamic fields
- Nested forms
- Multi-step forms
- Conditional fields
- Async validation
- Server-side validation
- Form performance
- Enterprise form architecture
Forms look simple at first—but large enterprise forms can become some of the most complex parts of a frontend application.
Happy Coding! 🚀
Top comments (0)