The React Native Boilerplate provides a robust API integration setup with Axios, TanStack Query, authentication handling, and comprehensive error management.
Axios with interceptors
TanStack Query for caching
Token-based auth
Setting up Axios with interceptors and base 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 }// 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}`,
},
},
}Organizing API calls into reusable service functions
// 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)
},
}// 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
},
}// 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')Using TanStack Query for efficient data fetching and caching
// 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>
)
}// 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'])
},
})
}// 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])
},
})
}Comprehensive error handling strategies for API calls
// 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')
}
}// 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
}
}// 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 }
}Implementing secure authentication with token management
// 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,
}
}// 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
}
},
}Guidelines for efficient and maintainable API integration