Skip to content

API Layer and Data Fetching

RednGift communicates with a REST API for all server operations:

  • CRUD operations on groups, wishlists, and items
  • User authentication and profile management
  • Invitations and raffle generation
  • Image uploads to CDN
  • Single backend: All requests go to one API base URL
  • Authentication: JWT token must be attached to every authenticated request
  • Error handling: Network errors and API errors must be caught and reported
  • Type safety: Request/response payloads should be typed
  • Centralization: Avoid duplicating fetch logic across components
  • Interceptors: Automatically attach auth headers and handle 401 responses
  • Request cancellation: Abort in-flight requests on component unmount
  • Progress tracking: Monitor upload/download progress for large files
  • Browser compatibility: Polyfills for older browsers
  • Type definitions: Built-in TypeScript support
  • Organization: Group related API calls by domain (groups, wishlists, users)
  • Reusability: Call groupsHandlers.createNewGroup() from any component
  • Testability: Mock handlers in tests without touching components
  • Consistency: Standardized error handling and response parsing
// src/utils/axios.ts (simplified)
import axios, { AxiosRequestConfig } from 'axios';
import { HOST_API } from 'src/config-global';
const axiosInstance = axios.create({
baseURL: HOST_API, // e.g., https://api.redngift.com
timeout: 30000, // 30 seconds
});
// Request interceptor: attach auth token
axiosInstance.interceptors.request.use(
(config) => {
const accessToken = localStorage.getItem('accessToken');
if (accessToken) {
config.headers.Authorization = `Bearer ${accessToken}`;
}
return config;
},
(error) => {
console.error('Request error:', error);
return Promise.reject(error);
}
);
// Response interceptor: handle global errors
axiosInstance.interceptors.response.use(
(response) => response,
(error) => {
if (error.response?.status === 401) {
// Token expired or invalid
localStorage.removeItem('accessToken');
window.location.href = '/auth/login';
}
return Promise.reject(error);
}
);
export default axiosInstance;
src/services/httpClient/httpClient.ts
import axios from 'src/utils/axios';
import { AxiosResponse } from 'axios';
export const getRequest = async (endpoint: string): Promise<AxiosResponse> => {
return axios.get(endpoint);
};
export const postRequest = async (
endpoint: string,
data: any
): Promise<AxiosResponse> => {
return axios.post(endpoint, data);
};
export const patchRequest = async (
endpoint: string,
data: any
): Promise<AxiosResponse> => {
return axios.patch(endpoint, data);
};
export const deleteRequest = async (
endpoint: string,
data?: any
): Promise<AxiosResponse> => {
return axios.delete(endpoint, { data });
};
export const putImageRequest = async (
endpoint: string,
image: File
): Promise<AxiosResponse> => {
const formData = new FormData();
formData.append('image', image);
return axios.put(endpoint, formData, {
headers: { 'Content-Type': 'multipart/form-data' },
});
};
// src/services/handlers/groups/groupsHandlers.ts (simplified)
import { getRequest, postRequest, patchRequest, deleteRequest } from 'src/services/httpClient/httpClient';
import { AxiosResponse } from 'axios';
import { GetUserGroupsResponse, NewGroupData } from './types';
const createNewGroup = async (data: NewGroupData) => {
try {
const res = await postRequest('groups', data);
return res.data;
} catch (error) {
console.error('Error creating new group', error);
Sentry.captureException(error);
throw error;
}
};
const getUserGroups = async () => {
try {
const res = (await getRequest('groups/me')) as AxiosResponse<GetUserGroupsResponse>;
return res.data;
} catch (error) {
console.error('Error getting user groups', error);
Sentry.captureException(error);
throw error;
}
};
const getGroupById = async (id: number) => {
try {
const res = await getRequest(`groups/${id}`);
return res.data;
} catch (error) {
console.error('Error getting group by id', error);
Sentry.captureException(error);
throw error;
}
};
const updateGroupData = async (id: number, data: NewGroupData) => {
try {
const res = await patchRequest(`groups/${id}`, data);
return res.data;
} catch (error) {
console.error('Error updating group data', error);
Sentry.captureException(error);
throw error;
}
};
const deleteGroup = async (id: number) => {
try {
const res = await deleteRequest(`groups/${id}`, {});
return res.data;
} catch (error) {
console.error('Error deleting group', error);
Sentry.captureException(error);
throw error;
}
};
const generateRaffle = async (id: number) => {
try {
const res = await postRequest(`groups/${id}/raffle`, {});
return res.data;
} catch (error) {
console.error('Error generating raffle', error);
Sentry.captureException(error);
throw error;
}
};
export const groupsHandlers = {
createNewGroup,
getUserGroups,
getGroupById,
updateGroupData,
deleteGroup,
generateRaffle,
// ... more methods
};
src/services/handlers/groups/types.ts
import { Event } from 'src/types/groups.types';
export interface NewGroupData {
name: string;
budget: number;
date: string;
rules?: string;
image?: string;
}
export interface GetUserGroupsResponse {
success: boolean;
groups: Event[];
}
export interface newGroupResponse {
success: boolean;
group: Event;
}
Component mounts
Call groupsHandlers.getUserGroups()
HTTP GET /groups/me
├─ Request interceptor ────────┐
│ │
│ ▼
│ Attach Authorization header
│ │
│ ▼
│ Send request to backend
├─ Success (200) ──────────────┐
│ │
│ ▼
│ Response interceptor
│ │
│ ▼
│ Return response.data
│ │
│ ▼
│ Update Redux state
│ │
│ ▼
│ Render UI with data
└─ Error (401) ────────────────┐
Response interceptor detects 401
Clear localStorage token
Redirect to /auth/login
// Component code
const handleCreateGroup = async (formData: NewGroupData) => {
try {
setLoading(true);
const result = await groupsHandlers.createNewGroup(formData);
toast.success('Group created successfully!');
router.push(paths.home.group.main(result.group.id));
} catch (error) {
if (error.response?.status === 400) {
toast.error('Invalid data. Please check your inputs.');
} else {
toast.error('Failed to create group. Please try again.');
}
Sentry.captureException(error, {
tags: { action: 'createGroup' },
extra: { formData },
});
} finally {
setLoading(false);
}
};
src/utils/redux/slices/groupsSlice.ts
import { createAsyncThunk } from '@reduxjs/toolkit';
import { groupsHandlers } from 'src/services/handlers/groups/groupsHandlers';
export const fetchUserGroups = createAsyncThunk(
'groups/fetch',
async (_, { rejectWithValue }) => {
try {
const response = await groupsHandlers.getUserGroups();
return response;
} catch (error) {
console.error(error);
Sentry.captureException(error);
return rejectWithValue(error.response?.data || 'Unknown error');
}
}
);
// Usage in component
const dispatch = useDispatch();
useEffect(() => {
dispatch(fetchUserGroups());
}, [dispatch]);
// Component code
const handleUploadImage = async (groupId: number, file: File) => {
try {
const result = await groupsHandlers.uploadGroupImage(groupId, file);
// Update Redux with new image URL
dispatch(updateGroupImage({
groupId,
image: result.image,
imageBlurhash: result.imageBlurhash,
}));
toast.success('Image uploaded successfully!');
} catch (error) {
toast.error('Failed to upload image.');
Sentry.captureException(error);
}
};

DO:

  • Always use domain handlers for API calls: groupsHandlers.createNewGroup()
  • Catch errors at the handler level and re-throw for component handling
  • Log all errors to Sentry with context
  • Use typed responses: AxiosResponse<GetUserGroupsResponse>

DON’T:

  • Don’t call axios directly from components
  • Don’t hardcode API URLs: use endpoints or handler methods
  • Don’t swallow errors without logging
  • Don’t store entire API responses in Redux (extract data only)

Cancel in-flight requests on component unmount:

useEffect(() => {
const controller = new AbortController();
const fetchData = async () => {
try {
const res = await axios.get('groups/me', {
signal: controller.signal,
});
setData(res.data);
} catch (error) {
if (axios.isCancel(error)) {
console.log('Request canceled');
} else {
console.error(error);
}
}
};
fetchData();
return () => {
controller.abort(); // Cancel on unmount
};
}, []);

Retry failed requests with exponential backoff:

const axiosRetry = require('axios-retry');
axiosRetry(axiosInstance, {
retries: 3,
retryDelay: (retryCount) => retryCount * 1000, // 1s, 2s, 3s
retryCondition: (error) => {
// Retry on network errors or 5xx server errors
return axiosRetry.isNetworkOrIdempotentRequestError(error) ||
error.response?.status >= 500;
},
});

Network error (no response):

catch (error) {
if (error.code === 'ECONNABORTED') {
toast.error('Request timeout. Please check your connection.');
} else if (!error.response) {
toast.error('Network error. Please try again.');
}
}

API error (response received):

catch (error) {
const status = error.response?.status;
const message = error.response?.data?.message;
if (status === 400) {
toast.error(message || 'Invalid request');
} else if (status === 404) {
toast.error('Resource not found');
} else if (status === 500) {
toast.error('Server error. Please try again later.');
}
}
  • Request deduplication: Prevent duplicate API calls for the same resource
  • Caching: Use Redux to cache responses and avoid redundant fetches
  • Pagination: Fetch large lists in chunks (not implemented yet)
  • Compression: Enable gzip compression on backend
// Request deduplication example
const pendingRequests = new Map();
const getRequest = async (endpoint: string) => {
if (pendingRequests.has(endpoint)) {
return pendingRequests.get(endpoint);
}
const promise = axios.get(endpoint);
pendingRequests.set(endpoint, promise);
try {
const response = await promise;
return response;
} finally {
pendingRequests.delete(endpoint);
}
};
import { groupsHandlers } from './groupsHandlers';
import * as httpClient from 'src/services/httpClient/httpClient';
jest.mock('src/services/httpClient/httpClient');
describe('groupsHandlers', () => {
it('should fetch user groups', async () => {
const mockGroups = [{ id: 1, name: 'Test Group' }];
jest.spyOn(httpClient, 'getRequest').mockResolvedValue({
data: { groups: mockGroups },
});
const result = await groupsHandlers.getUserGroups();
expect(httpClient.getRequest).toHaveBeenCalledWith('groups/me');
expect(result.groups).toEqual(mockGroups);
});
it('should handle errors gracefully', async () => {
jest.spyOn(httpClient, 'getRequest').mockRejectedValue(new Error('Network error'));
await expect(groupsHandlers.getUserGroups()).rejects.toThrow('Network error');
});
});
import { renderHook } from '@testing-library/react';
import { Provider } from 'react-redux';
import { store } from 'src/utils/redux/store';
import { fetchUserGroups } from 'src/utils/redux/slices/groupsSlice';
it('should fetch groups and update store', async () => {
const mockGroups = [{ id: 1, name: 'Test' }];
jest.spyOn(groupsHandlers, 'getUserGroups').mockResolvedValue(mockGroups);
const { result } = renderHook(() => store, {
wrapper: ({ children }) => <Provider store={store}>{children}</Provider>,
});
await store.dispatch(fetchUserGroups());
expect(result.current.getState().groups.userGroups).toEqual(mockGroups);
});
  • Create group via UI → verify POST request sent with correct payload
  • Navigate to group page → verify GET request fetches group data
  • Delete group → verify DELETE request and UI updates

Replace REST handlers with GraphQL queries:

src/services/graphql/client.ts
import { ApolloClient, InMemoryCache } from '@apollo/client';
export const apolloClient = new ApolloClient({
uri: process.env.NEXT_PUBLIC_GRAPHQL_API,
cache: new InMemoryCache(),
headers: {
authorization: `Bearer ${localStorage.getItem('accessToken')}`,
},
});
// src/services/handlers/groups/groupsHandlers.ts
import { gql } from '@apollo/client';
import { apolloClient } from 'src/services/graphql/client';
const GET_USER_GROUPS = gql`
query GetUserGroups {
groups {
id
name
members { id name }
}
}
`;
const getUserGroups = async () => {
const { data } = await apolloClient.query({ query: GET_USER_GROUPS });
return data.groups;
};

Use swr for client-side caching:

import useSWR from 'swr';
const fetcher = (url: string) => axios.get(url).then((res) => res.data);
export function useUserGroups() {
const { data, error, mutate } = useSWR('groups/me', fetcher, {
revalidateOnFocus: false,
dedupingInterval: 60000, // Cache for 1 minute
});
return {
groups: data?.groups || [],
loading: !error && !data,
error,
refresh: mutate,
};
}

Update UI immediately, rollback on error:

const handleDeleteGroup = async (groupId: number) => {
// Optimistically remove from Redux
dispatch(removeGroup(groupId));
try {
await groupsHandlers.deleteGroup(groupId);
toast.success('Group deleted');
} catch (error) {
// Rollback on error
dispatch(addGroup(originalGroup));
toast.error('Failed to delete group');
}
};