Skip to main content

Introduction

The API client (src/app/api/client.ts) provides a centralized interface for making HTTP requests to the backend. It includes built-in support for authentication, retries, timeouts, and error handling.

Core Functions

The API client exports five main functions:

request()

Core request function with retry and timeout logic

apiGET()

Wrapper for GET requests

apiPOST()

Wrapper for POST requests

apiPATCH()

Wrapper for PATCH requests

apiDELETE()

Wrapper for DELETE requests

Request Function

The request() function is the foundation of the API client:
import { useAuthStore } from "../../app/store/auth/auth.store";

const API_BASE_URL = import.meta.env.VITE_API_URL;

export async function request(
  endpoint: string, 
  options: RequestInit & { retry?: number, timeOut?: number } = {}
) {
  const headers: HeadersInit = {
    ...(options.body ? { "Content-Type": "application/json" } : {}),
    ...options.headers,
  }

  // Separate retry, timeout and request headers
  const { retry = 0, timeOut = 1000 * 30, ...fetchOptions } = options

  const controller = new AbortController()
  const timeoutId = setTimeout(() => controller.abort(), timeOut)

  try {
    const response = await fetch(`${API_BASE_URL}${endpoint}`, {
      ...fetchOptions,
      credentials: "include",
      headers,
      signal: controller.signal
    })

    if (response.status === 401) {
      useAuthStore.getState().logout();
      throw new Error("Unauthorized");
    }

    if (!response.ok) {
      let messsage = "API request failed"
      try {
        const error = await response.json().catch(() => ({}))
        messsage = error.error || messsage
      } catch { }
      throw new Error(messsage)
    }

    return response.status === 204 ? null : response.json();
  } catch (error) {
    if (error instanceof DOMException && error.name === "AbortError") {
      throw new Error("Request timeout")
    }

    // Retry if error and ignore when the error is 401 or 403
    if (retry > 0 && !(error instanceof Error && 
        (error.message === "Unauthorized" || error.message === "Forbidden"))) {
      return request(endpoint, { ...options, retry: retry - 1 })
    }
    throw error
  } finally {
    clearTimeout(timeoutId)
  }
}

HTTP Method Wrappers

Convenience functions wrap the request() function:
export function apiGET(url: string, options: RequestInit = {}) {
  return request(url, { ...options, method: "GET" })
}

export function apiPOST(url: string, options: RequestInit = {}) {
  return request(url, { ...options, method: "POST" })
}

export function apiDELETE(url: string, options: RequestInit = {}) {
  return request(url, { ...options, method: "DELETE" })
}

export function apiPATCH(url: string, options: RequestInit = {}) {
  return request(url, { ...options, method: "PATCH" })
}

Usage Examples

GET Request

import { apiGET } from '@app/api/client';

const user = await apiGET('/auth/me');
console.log(user);

POST Request with Body

import { apiPOST } from '@app/api/client';

const response = await apiPOST('/auth/login', {
  body: JSON.stringify({
    email: '[email protected]',
    password: 'password123'
  })
});

PATCH Request

import { apiPATCH } from '@app/api/client';

const updated = await apiPATCH('/users/123', {
  body: JSON.stringify({
    name: 'New Name'
  })
});

DELETE Request

import { apiDELETE } from '@app/api/client';

await apiDELETE('/items/456');

Request with Custom Options

import { apiGET } from '@app/api/client';

const data = await apiGET('/slow-endpoint', {
  retry: 3,           // Retry up to 3 times
  timeOut: 10000,     // 10 second timeout
  headers: {
    'X-Custom-Header': 'value'
  }
});

Key Features

Automatic Authentication

1

Include Credentials

All requests include credentials: "include" to send cookies automatically.
2

Handle 401 Responses

When the server returns 401 Unauthorized, the client automatically logs out the user.
3

Clear Session

The auth store is updated to clear user session data (src/app/api/client.ts:28).
if (response.status === 401) {
  useAuthStore.getState().logout();
  throw new Error("Unauthorized");
}

Timeout Handling

Requests have a configurable timeout (default 30 seconds):
const controller = new AbortController()
const timeoutId = setTimeout(() => controller.abort(), timeOut)

try {
  const response = await fetch(url, {
    signal: controller.signal
  });
} finally {
  clearTimeout(timeoutId)
}
Timeouts use the AbortController API to cancel in-flight requests, preventing memory leaks.

Retry Logic

Failed requests can be automatically retried:
if (retry > 0 && !(error instanceof Error && 
    (error.message === "Unauthorized" || error.message === "Forbidden"))) {
  return request(endpoint, { ...options, retry: retry - 1 })
}
Retry behavior:
  • Retries occur when retry > 0 is specified
  • Does not retry on 401 (Unauthorized) or 403 (Forbidden)
  • Does not retry on timeout errors
  • Decrements retry count on each attempt
Retries are not performed for authentication errors (401, 403) to prevent loops and account lockouts.

Error Handling

The client provides detailed error messages:
if (!response.ok) {
  let messsage = "API request failed"
  try {
    const error = await response.json().catch(() => ({}))
    messsage = error.error || messsage
  } catch { }
  throw new Error(messsage)
}
1

Check Response Status

Verify response.ok (status 200-299).
2

Parse Error Message

Attempt to extract error message from response body.
3

Fallback Message

Use generic message if parsing fails.
4

Throw Error

Throw error for calling code to handle.

204 No Content Handling

Requests returning 204 No Content return null:
return response.status === 204 ? null : response.json();

Configuration

Base URL

The API base URL is configured via environment variable:
const API_BASE_URL = import.meta.env.VITE_API_URL;
VITE_API_URL=http://localhost:8000/api

Default Headers

Headers are automatically set based on request content:
const headers: HeadersInit = {
  ...(options.body ? { "Content-Type": "application/json" } : {}),
  ...options.headers,
}
  • Content-Type: application/json is added when body is present
  • Custom headers can be passed via options.headers
  • Cookie credentials are automatically included

Error Types

The API client throws different error types:
throw new Error("Request timeout")
Thrown when request exceeds the timeout duration.
throw new Error("Unauthorized")
Thrown on 401 status. Automatically logs out user.
throw new Error("API request failed")
Generic error with optional server-provided message.
Native fetch errors (network failure, CORS, etc.)

Usage in Stores

The API client is commonly used in Zustand stores:
import { create } from "zustand";
import { apiGET, apiPOST } from "@app/api/client";

export const useDataStore = create((set) => ({
  data: null,
  loading: false,
  
  fetchData: async () => {
    set({ loading: true });
    try {
      const data = await apiGET('/data');
      set({ data, loading: false });
    } catch (error) {
      set({ loading: false });
      console.error('Failed to fetch data:', error);
    }
  },
  
  createItem: async (item) => {
    const created = await apiPOST('/items', {
      body: JSON.stringify(item)
    });
    return created;
  },
}));

Usage in Services

Feature services use the API client for domain-specific operations:
// src/features/auth/services/auth.service.ts
import { apiPOST } from '@app/api/client';

export async function loginUser(credentials: LoginCredentials) {
  return apiPOST('/auth/login', {
    body: JSON.stringify(credentials),
    retry: 1,
    timeOut: 10000,
  });
}

export async function registerUser(data: RegisterData) {
  return apiPOST('/auth/register', {
    body: JSON.stringify(data)
  });
}

Best Practices

Keep all API interactions in the client or feature services - never use fetch() directly in components.
Always wrap API calls in try/catch blocks and provide user feedback.
Define TypeScript types for request/response data.
Set appropriate timeouts for slow endpoints (file uploads, reports, etc.).
Use retry for transient failures but not for user errors (4xx responses).

Advanced Usage

Custom Headers

await apiGET('/protected', {
  headers: {
    'Authorization': `Bearer ${token}`,
    'X-Custom-Header': 'value'
  }
});

File Uploads

const formData = new FormData();
formData.append('file', file);

await apiPOST('/upload', {
  body: formData,
  // Don't set Content-Type - browser will set it with boundary
  headers: {},
  timeOut: 60000, // 60 second timeout for large files
});

Abort Requests

const controller = new AbortController();

// Start request
const promise = apiGET('/slow-endpoint', {
  signal: controller.signal
});

// Cancel after 5 seconds
setTimeout(() => controller.abort(), 5000);

try {
  await promise;
} catch (error) {
  console.log('Request cancelled');
}

State Management

Learn how API client is used in Zustand stores

Architecture Overview

Understand the overall architecture

Fetch API

MDN documentation for the Fetch API

Build docs developers (and LLMs) love