API Layer and Data Fetching
API Layer and Data Fetching
Section titled “API Layer and Data Fetching”Problem and Rationale
Section titled “Problem and Rationale”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
Context and Constraints
Section titled “Context and Constraints”- 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
Why Axios?
Section titled “Why Axios?”- 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
Why Domain Handlers?
Section titled “Why Domain Handlers?”- 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
Design
Section titled “Design”HTTP Client Setup
Section titled “HTTP Client Setup”// 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 tokenaxiosInstance.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 errorsaxiosInstance.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;Generic HTTP Methods
Section titled “Generic HTTP Methods”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' }, });};Domain Handler Example (Groups)
Section titled “Domain Handler Example (Groups)”// 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};Type Definitions
Section titled “Type Definitions”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;}Lifecycle and Data Flow
Section titled “Lifecycle and Data Flow”1. Simple GET Request
Section titled “1. Simple GET Request”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/login2. POST Request with Error Handling
Section titled “2. POST Request with Error Handling”// Component codeconst 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); }};3. Redux Async Thunk Integration
Section titled “3. Redux Async Thunk Integration”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 componentconst dispatch = useDispatch();useEffect(() => { dispatch(fetchUserGroups());}, [dispatch]);4. Image Upload
Section titled “4. Image Upload”// Component codeconst 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); }};Implementation Notes
Section titled “Implementation Notes”Patterns
Section titled “Patterns”✅ 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
axiosdirectly from components - Don’t hardcode API URLs: use
endpointsor handler methods - Don’t swallow errors without logging
- Don’t store entire API responses in Redux (extract data only)
Request Cancellation
Section titled “Request Cancellation”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 Logic
Section titled “Retry Logic”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; },});Error Handling Strategies
Section titled “Error Handling Strategies”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.'); }}Performance Considerations
Section titled “Performance Considerations”- 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 exampleconst 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); }};Testing Strategy
Section titled “Testing Strategy”Unit Tests (Handlers)
Section titled “Unit Tests (Handlers)”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'); });});Integration Tests (With Redux)
Section titled “Integration Tests (With Redux)”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);});E2E Tests
Section titled “E2E Tests”- 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
Extending This Concept
Section titled “Extending This Concept”Adding GraphQL Support
Section titled “Adding GraphQL Support”Replace REST handlers with GraphQL queries:
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.tsimport { 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;};Adding Request Caching (SWR)
Section titled “Adding Request Caching (SWR)”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, };}Adding Optimistic Updates
Section titled “Adding Optimistic Updates”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'); }};Further Reading
Section titled “Further Reading”- Authentication - How JWT tokens are attached to requests
- State Management - Redux integration with async thunks
- Error Handling - Sentry error tracking
- Secret Santa Groups - Groups API in action