API Integration

Complete guide to API integration and data management

API Integration Overview

The React Native Boilerplate provides a robust API integration setup with Axios, TanStack Query, authentication handling, and comprehensive error management.

HTTP Client

Axios with interceptors

Data Fetching

TanStack Query for caching

Authentication

Token-based auth

HTTP Client Configuration

Setting up Axios with interceptors and base configuration

Base Axios Configuration

// services/api/client.ts
import axios, { AxiosInstance, AxiosRequestConfig, AxiosResponse } from 'axios'
import { config } from '@/config'
import { storage } from '@/services/storage'

// Create axios instance
const apiClient: AxiosInstance = axios.create({
  baseURL: config.API_BASE_URL,
  timeout: 30000,
  headers: {
    'Content-Type': 'application/json',
    'Accept': 'application/json',
  },
})

// Request interceptor for auth token
apiClient.interceptors.request.use(
  async (config: AxiosRequestConfig) => {
    const token = await storage.getToken()
    
    if (token) {
      config.headers = {
        ...config.headers,
        Authorization: `Bearer ${token}`,
      }
    }
    
    return config
  },
  (error) => {
    return Promise.reject(error)
  }
)

// Response interceptor for error handling
apiClient.interceptors.response.use(
  (response: AxiosResponse) => {
    return response
  },
  async (error) => {
    const originalRequest = error.config
    
    // Handle 401 Unauthorized
    if (error.response?.status === 401 && !originalRequest._retry) {
      originalRequest._retry = true
      
      try {
        const refreshToken = await storage.getRefreshToken()
        if (refreshToken) {
          const response = await refreshAuthToken(refreshToken)
          await storage.setToken(response.data.accessToken)
          
          // Retry original request
          return apiClient(originalRequest)
        }
      } catch (refreshError) {
        // Refresh failed, redirect to login
        await storage.clearTokens()
        // Navigate to login screen
      }
    }
    
    return Promise.reject(error)
  }
)

export { apiClient }

API Configuration

// config/api.ts
export const config = {
  API_BASE_URL: process.env.EXPO_PUBLIC_API_BASE_URL || 'https://api.example.com',
  API_TIMEOUT: 30000,
  
  // API Endpoints
  endpoints: {
    auth: {
      login: '/auth/login',
      register: '/auth/register',
      refresh: '/auth/refresh',
      logout: '/auth/logout',
    },
    user: {
      profile: '/user/profile',
      update: '/user/profile',
    },
    posts: {
      list: '/posts',
      create: '/posts',
      detail: (id: string) => `/posts/${id}`,
      update: (id: string) => `/posts/${id}`,
      delete: (id: string) => `/posts/${id}`,
    },
  },
}

API Service Layer

Organizing API calls into reusable service functions

Authentication Service

// services/api/authService.ts
import { apiClient } from './client'
import { config } from '@/config'

export interface LoginRequest {
  email: string
  password: string
}

export interface AuthResponse {
  user: User
  accessToken: string
  refreshToken: string
}

export const authService = {
  login: async (credentials: LoginRequest): Promise<AuthResponse> => {
    const response = await apiClient.post(config.endpoints.auth.login, credentials)
    return response.data
  },

  register: async (userData: RegisterRequest): Promise<AuthResponse> => {
    const response = await apiClient.post(config.endpoints.auth.register, userData)
    return response.data
  },

  refreshToken: async (refreshToken: string): Promise<AuthResponse> => {
    const response = await apiClient.post(config.endpoints.auth.refresh, {
      refreshToken,
    })
    return response.data
  },

  logout: async (): Promise<void> => {
    await apiClient.post(config.endpoints.auth.logout)
  },
}

User Service

// services/api/userService.ts
import { apiClient } from './client'
import { config } from '@/config'

export interface User {
  id: string
  name: string
  email: string
  avatar?: string
  createdAt: string
}

export interface UpdateUserRequest {
  name?: string
  avatar?: string
}

export const userService = {
  getProfile: async (): Promise<User> => {
    const response = await apiClient.get(config.endpoints.user.profile)
    return response.data
  },

  updateProfile: async (updates: UpdateUserRequest): Promise<User> => {
    const response = await apiClient.put(config.endpoints.user.update, updates)
    return response.data
  },

  uploadAvatar: async (file: FormData): Promise<{ url: string }> => {
    const response = await apiClient.post('/user/avatar', file, {
      headers: {
        'Content-Type': 'multipart/form-data',
      },
    })
    return response.data
  },
}

Generic API Service

// services/api/baseService.ts
import { apiClient } from './client'

export class BaseApiService<T> {
  constructor(private endpoint: string) {}

  async getAll(params?: Record<string, any>): Promise<T[]> {
    const response = await apiClient.get(this.endpoint, { params })
    return response.data
  }

  async getById(id: string): Promise<T> {
    const response = await apiClient.get(`${this.endpoint}/${id}`)
    return response.data
  }

  async create(data: Partial<T>): Promise<T> {
    const response = await apiClient.post(this.endpoint, data)
    return response.data
  }

  async update(id: string, data: Partial<T>): Promise<T> {
    const response = await apiClient.put(`${this.endpoint}/${id}`, data)
    return response.data
  }

  async delete(id: string): Promise<void> {
    await apiClient.delete(`${this.endpoint}/${id}`)
  }
}

// Usage example
export const postsService = new BaseApiService<Post>('/posts')

TanStack Query Integration

Using TanStack Query for efficient data fetching and caching

Query Client Setup

// providers/QueryProvider.tsx
import React from 'react'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { ReactQueryDevtools } from '@tanstack/react-query-devtools'

const queryClient = new QueryClient({
  defaultOptions: {
    queries: {
      staleTime: 5 * 60 * 1000, // 5 minutes
      cacheTime: 10 * 60 * 1000, // 10 minutes
      retry: 2,
      refetchOnWindowFocus: false,
    },
    mutations: {
      retry: 1,
    },
  },
})

export const QueryProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
  return (
    <QueryClientProvider client={queryClient}>
      {children}
      {__DEV__ && <ReactQueryDevtools initialIsOpen={false} />}
    </QueryClientProvider>
  )
}

Custom Query Hooks

// hooks/queries/useAuth.ts
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { authService } from '@/services/api'

export const useUserProfile = () => {
  return useQuery({
    queryKey: ['user', 'profile'],
    queryFn: userService.getProfile,
    enabled: !!storage.getToken(), // Only fetch if authenticated
  })
}

export const useUpdateProfile = () => {
  const queryClient = useQueryClient()

  return useMutation({
    mutationFn: userService.updateProfile,
    onSuccess: (updatedUser) => {
      // Update cache
      queryClient.setQueryData(['user', 'profile'], updatedUser)
      
      // Show success message
      showToast('Profile updated successfully', 'success')
    },
    onError: (error) => {
      showToast('Failed to update profile', 'error')
    },
  })
}

// Posts queries
export const usePosts = (params?: PostsParams) => {
  return useQuery({
    queryKey: ['posts', params],
    queryFn: () => postsService.getAll(params),
    keepPreviousData: true, // For pagination
  })
}

export const usePost = (id: string) => {
  return useQuery({
    queryKey: ['posts', id],
    queryFn: () => postsService.getById(id),
    enabled: !!id,
  })
}

export const useCreatePost = () => {
  const queryClient = useQueryClient()

  return useMutation({
    mutationFn: postsService.create,
    onSuccess: () => {
      // Invalidate and refetch posts list
      queryClient.invalidateQueries(['posts'])
    },
  })
}

Optimistic Updates

// hooks/queries/useOptimisticPost.ts
export const useUpdatePost = () => {
  const queryClient = useQueryClient()

  return useMutation({
    mutationFn: ({ id, data }: { id: string; data: Partial<Post> }) =>
      postsService.update(id, data),
    
    // Optimistic update
    onMutate: async ({ id, data }) => {
      // Cancel outgoing refetches
      await queryClient.cancelQueries(['posts', id])

      // Snapshot the previous value
      const previousPost = queryClient.getQueryData(['posts', id])

      // Optimistically update to the new value
      queryClient.setQueryData(['posts', id], (old: Post) => ({
        ...old,
        ...data,
      }))

      // Return context with snapshot
      return { previousPost, id }
    },

    // If mutation fails, rollback
    onError: (err, variables, context) => {
      if (context?.previousPost) {
        queryClient.setQueryData(['posts', context.id], context.previousPost)
      }
    },

    // Always refetch after error or success
    onSettled: (data, error, variables) => {
      queryClient.invalidateQueries(['posts', variables.id])
    },
  })
}

Error Handling

Comprehensive error handling strategies for API calls

Error Types & Handling

// types/api.ts
export interface ApiError {
  message: string
  status: number
  code?: string
  details?: Record<string, any>
}

export class NetworkError extends Error {
  constructor(message: string) {
    super(message)
    this.name = 'NetworkError'
  }
}

export class ValidationError extends Error {
  constructor(message: string, public fields: Record<string, string[]>) {
    super(message)
    this.name = 'ValidationError'
  }
}

// utils/errorHandler.ts
export const handleApiError = (error: any): ApiError => {
  if (error.response) {
    // Server responded with error status
    const { status, data } = error.response
    
    return {
      message: data.message || 'An error occurred',
      status,
      code: data.code,
      details: data.details,
    }
  } else if (error.request) {
    // Network error
    throw new NetworkError('Network connection failed')
  } else {
    // Other error
    throw new Error(error.message || 'An unexpected error occurred')
  }
}

Error Boundary for API Errors

// components/ErrorBoundary.tsx
import React from 'react'
import { View, Text, TouchableOpacity } from 'react-native'

interface ErrorBoundaryState {
  hasError: boolean
  error?: Error
}

export class ApiErrorBoundary extends React.Component<
  React.PropsWithChildren<{}>,
  ErrorBoundaryState
> {
  constructor(props: React.PropsWithChildren<{}>) {
    super(props)
    this.state = { hasError: false }
  }

  static getDerivedStateFromError(error: Error): ErrorBoundaryState {
    return { hasError: true, error }
  }

  componentDidCatch(error: Error, errorInfo: React.ErrorInfo) {
    console.error('API Error Boundary caught an error:', error, errorInfo)
    
    // Log to crash reporting service
    crashlytics().recordError(error)
  }

  render() {
    if (this.state.hasError) {
      return (
        <View style={styles.errorContainer}>
          <Text style={styles.errorTitle}>Oops! Something went wrong</Text>
          <Text style={styles.errorMessage}>
            {this.state.error?.message || 'An unexpected error occurred'}
          </Text>
          <TouchableOpacity
            style={styles.retryButton}
            onPress={() => this.setState({ hasError: false })}
          >
            <Text style={styles.retryButtonText}>Try Again</Text>
          </TouchableOpacity>
        </View>
      )
    }

    return this.props.children
  }
}

Global Error Handler Hook

// hooks/useErrorHandler.ts
import { useCallback } from 'react'
import { Alert } from 'react-native'
import { useNavigation } from '@react-navigation/native'

export const useErrorHandler = () => {
  const navigation = useNavigation()

  const handleError = useCallback((error: any) => {
    const apiError = handleApiError(error)

    switch (apiError.status) {
      case 401:
        // Unauthorized - redirect to login
        Alert.alert(
          'Session Expired',
          'Please log in again to continue.',
          [
            {
              text: 'OK',
              onPress: () => navigation.navigate('Login'),
            },
          ]
        )
        break

      case 403:
        // Forbidden
        Alert.alert('Access Denied', 'You do not have permission to perform this action.')
        break

      case 422:
        // Validation errors
        if (apiError.details) {
          const fieldErrors = Object.entries(apiError.details)
            .map(([field, errors]) => `${field}: ${errors.join(', ')}`)
            .join('\n')
          
          Alert.alert('Validation Error', fieldErrors)
        }
        break

      case 500:
        // Server error
        Alert.alert('Server Error', 'Something went wrong on our end. Please try again later.')
        break

      default:
        // Generic error
        Alert.alert('Error', apiError.message)
    }
  }, [navigation])

  return { handleError }
}

Authentication Flow

Implementing secure authentication with token management

Authentication Hook

// hooks/useAuth.ts
import { useState, useEffect } from 'react'
import { storage } from '@/services/storage'
import { authService } from '@/services/api'

export const useAuth = () => {
  const [user, setUser] = useState<User | null>(null)
  const [isLoading, setIsLoading] = useState(true)
  const [isAuthenticated, setIsAuthenticated] = useState(false)

  useEffect(() => {
    checkAuthStatus()
  }, [])

  const checkAuthStatus = async () => {
    try {
      const token = await storage.getToken()
      if (token) {
        const userProfile = await userService.getProfile()
        setUser(userProfile)
        setIsAuthenticated(true)
      }
    } catch (error) {
      // Token invalid, clear storage
      await storage.clearTokens()
    } finally {
      setIsLoading(false)
    }
  }

  const login = async (email: string, password: string) => {
    try {
      const response = await authService.login({ email, password })
      
      // Store tokens
      await storage.setToken(response.accessToken)
      await storage.setRefreshToken(response.refreshToken)
      
      // Update state
      setUser(response.user)
      setIsAuthenticated(true)
      
      return response
    } catch (error) {
      throw error
    }
  }

  const logout = async () => {
    try {
      await authService.logout()
    } catch (error) {
      // Continue with logout even if server call fails
      console.warn('Logout API call failed:', error)
    } finally {
      // Clear local storage and state
      await storage.clearTokens()
      setUser(null)
      setIsAuthenticated(false)
    }
  }

  return {
    user,
    isLoading,
    isAuthenticated,
    login,
    logout,
  }
}

Token Storage Service

// services/storage/tokenStorage.ts
import { MMKV } from 'react-native-mmkv'

const storage = new MMKV({
  id: 'auth-storage',
  encryptionKey: 'auth-encryption-key',
})

export const tokenStorage = {
  setToken: (token: string) => {
    storage.set('accessToken', token)
  },

  getToken: (): string | undefined => {
    return storage.getString('accessToken')
  },

  setRefreshToken: (token: string) => {
    storage.set('refreshToken', token)
  },

  getRefreshToken: (): string | undefined => {
    return storage.getString('refreshToken')
  },

  clearTokens: () => {
    storage.delete('accessToken')
    storage.delete('refreshToken')
  },

  hasValidToken: (): boolean => {
    const token = storage.getString('accessToken')
    if (!token) return false

    // Check if token is expired
    try {
      const payload = JSON.parse(atob(token.split('.')[1]))
      const currentTime = Date.now() / 1000
      return payload.exp > currentTime
    } catch {
      return false
    }
  },
}

API Best Practices

Guidelines for efficient and maintainable API integration

✅ Do
  • • Use TypeScript interfaces for API responses
  • • Implement proper error handling and user feedback
  • • Cache data appropriately with TanStack Query
  • • Use optimistic updates for better UX
  • • Implement request/response interceptors
  • • Handle authentication token refresh automatically
  • • Use environment variables for API configuration
❌ Don't
  • • Store sensitive data in unencrypted storage
  • • Make API calls directly in components
  • • Ignore network errors or timeout handling
  • • Hard-code API endpoints
  • • Skip request validation
  • • Forget to handle loading states
  • • Use synchronous storage operations

Performance Tips

Pagination:Use infinite queries for large datasets
Debouncing:Debounce search API calls
Background Sync:Sync data when app becomes active
Request Deduplication:Prevent duplicate API calls