DEV Community

Siva Samanthapudi
Siva Samanthapudi

Posted on

React Mastery Series – Day 33: React API Architecture – Axios, Fetch, Service Layers, Interceptors & Error Handling

Welcome back to the React Mastery Series!

In Day 32, we explored State Management Architecture and learned how to decide between:

  • useState
  • useReducer
  • 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");
Enter fullscreen mode Exit fullscreen mode

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 />;
}
Enter fullscreen mode Exit fullscreen mode

It looks simple.

But now imagine every component contains:

API URL
Authentication
Headers
Error Handling
Retry Logic
Response Transformation
Logging
Enter fullscreen mode Exit fullscreen mode

The application becomes tightly coupled to the backend.


Better Architecture

A scalable architecture separates responsibilities:

React Component
       │
       ▼
Custom Hook
       │
       ▼
Service Layer
       │
       ▼
API Client
       │
       ▼
Backend
Enter fullscreen mode Exit fullscreen mode

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>
  );
}
Enter fullscreen mode Exit fullscreen mode

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,
  };
}
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

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,
});
Enter fullscreen mode Exit fullscreen mode

Now all services use the same client.


Why Create an Axios Instance?

Instead of:

axios.get(...);
axios.post(...);
axios.put(...);
Enter fullscreen mode Exit fullscreen mode

everywhere, use:

api.get(...);
api.post(...);
api.put(...);
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

Instead of adding it manually:

api.get("/users", {
  headers: {
    Authorization: `Bearer ${token}`,
  },
});
Enter fullscreen mode Exit fullscreen mode

we can use an interceptor.

api.interceptors.request.use(
  (config) => {
    const token = getAccessToken();

    if (token) {
      config.headers.Authorization =
        `Bearer ${token}`;
    }

    return config;
  }
);
Enter fullscreen mode Exit fullscreen mode

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);
  }
);
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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);
  }
);
Enter fullscreen mode Exit fullscreen mode

401 vs 403

This distinction is important.

401 – Unauthorized

Usually means:

The request doesn't have valid authentication credentials.

Example:

Access Token Expired
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Conceptually:

api.interceptors.response.use(
  (response) => response,
  async (error) => {
    if (
      error.response?.status === 401
    ) {
      // Refresh token
      // Retry original request
    }

    return Promise.reject(error);
  }
);
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

If every request independently refreshes the token:

5 Requests
   ↓
5 Refresh Requests
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Canceling unnecessary requests can reduce resource usage.

Modern fetch uses AbortController.

const controller =
  new AbortController();

fetch("/api/users", {
  signal: controller.signal,
});

controller.abort();
Enter fullscreen mode Exit fullscreen mode

Axios also supports cancellation using AbortSignal.


Search Request Example

Without cancellation:

r
re
rea
reac
react
Enter fullscreen mode Exit fullscreen mode

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,
});
Enter fullscreen mode Exit fullscreen mode

Retry Strategy

Not every failed request should be retried.

For example:

GET /accounts
Enter fullscreen mode Exit fullscreen mode

might be safely retryable.

But automatically retrying:

POST /payments
Enter fullscreen mode Exit fullscreen mode

can be dangerous if the operation is not idempotent.

Imagine:

Payment Request
↓
Timeout
↓
Client Retries
↓
Payment Processed Twice
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

is generally safe to repeat.

But:

POST /payments
Enter fullscreen mode Exit fullscreen mode

may create multiple transactions if the server doesn't provide idempotency protection.

Enterprise payment systems often use an idempotency key.

Example:

Idempotency-Key: 8f2c1d7a
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

Then:

const response =
  await api.get<User[]>("/users");
Enter fullscreen mode Exit fullscreen mode

Now TypeScript understands the response.


Request Types

Define request models too.

export interface CreateUserRequest {
  name: string;
  email: string;
  role: string;
}
Enter fullscreen mode Exit fullscreen mode

Service:

export async function createUser(
  payload: CreateUserRequest
) {
  const response =
    await api.post<User>(
      "/users",
      payload
    );

  return response.data;
}
Enter fullscreen mode Exit fullscreen mode

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"
}
Enter fullscreen mode Exit fullscreen mode

The UI might prefer:

{
  fullName: "Siva Samanthapudi"
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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,
  });
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Each layer has a clear responsibility.


Example

Service:

export async function getAccounts() {
  const response =
    await api.get<Account[]>("/accounts");

  return response.data;
}
Enter fullscreen mode Exit fullscreen mode

Hook:

export function useAccounts() {
  return useQuery({
    queryKey: ["accounts"],
    queryFn: getAccounts,
  });
}
Enter fullscreen mode Exit fullscreen mode

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>
  );
}
Enter fullscreen mode Exit fullscreen mode

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;
};
Enter fullscreen mode Exit fullscreen mode

Then transform backend errors into a consistent format.

Backend Errors
      ↓
API Client
      ↓
Normalized Error
      ↓
Application
      ↓
User-Friendly Message
Enter fullscreen mode Exit fullscreen mode

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");
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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)