--- name: adynato-mobile-api description: API integration patterns for Adynato mobile apps. Covers data fetching with TanStack Query, authentication flows, offline support, error handling, and optimistic updates in React Native/Expo apps. Use when integrating APIs into mobile applications. --- # Mobile API Skill Use this skill when integrating APIs into Adynato mobile apps. ## Stack - **Data Fetching**: TanStack Query (React Query) - **HTTP Client**: Fetch API or Axios - **Auth Storage**: expo-secure-store - **Offline**: TanStack Query persistence ## Setup ### Query Client Configuration ```typescript // lib/query-client.ts import { QueryClient } from '@tanstack/react-query' export const queryClient = new QueryClient({ defaultOptions: { queries: { staleTime: 1000 * 60 * 5, // 5 minutes gcTime: 1000 * 60 * 30, // 30 minutes (formerly cacheTime) retry: 2, refetchOnWindowFocus: false, // Mobile doesn't have window focus }, mutations: { retry: 1, }, }, }) ``` ### Provider Setup ```tsx // app/_layout.tsx import { QueryClientProvider } from '@tanstack/react-query' import { queryClient } from '@/lib/query-client' export default function RootLayout() { return ( ) } ``` ## API Client ### Base Configuration ```typescript // lib/api.ts import * as SecureStore from 'expo-secure-store' const API_URL = process.env.EXPO_PUBLIC_API_URL interface RequestOptions extends RequestInit { requireAuth?: boolean } export async function api( endpoint: string, options: RequestOptions = {} ): Promise { const { requireAuth = true, ...fetchOptions } = options const headers: HeadersInit = { 'Content-Type': 'application/json', ...fetchOptions.headers, } if (requireAuth) { const token = await SecureStore.getItemAsync('auth_token') if (token) { headers['Authorization'] = `Bearer ${token}` } } const response = await fetch(`${API_URL}${endpoint}`, { ...fetchOptions, headers, }) if (!response.ok) { const error = await response.json().catch(() => ({})) throw new ApiError(response.status, error.error || 'Request failed') } // Handle 204 No Content if (response.status === 204) { return undefined as T } return response.json() } export class ApiError extends Error { constructor(public status: number, message: string) { super(message) this.name = 'ApiError' } } ``` ### API Functions ```typescript // lib/api/users.ts import { api } from '@/lib/api' export interface User { id: string email: string name: string } export const usersApi = { getMe: () => api<{ data: User }>('/api/users/me'), getById: (id: string) => api<{ data: User }>(`/api/users/${id}`), update: (id: string, data: Partial) => api<{ data: User }>(`/api/users/${id}`, { method: 'PATCH', body: JSON.stringify(data), }), } ``` ## Query Hooks ### Basic Query ```typescript // hooks/useUser.ts import { useQuery } from '@tanstack/react-query' import { usersApi } from '@/lib/api/users' export function useUser(id: string) { return useQuery({ queryKey: ['users', id], queryFn: () => usersApi.getById(id), enabled: !!id, }) } ``` ### Query with Transform ```typescript export function useCurrentUser() { return useQuery({ queryKey: ['users', 'me'], queryFn: usersApi.getMe, select: (response) => response.data, // Extract data from wrapper }) } ``` ### Paginated Query ```typescript import { useInfiniteQuery } from '@tanstack/react-query' export function useUsersList() { return useInfiniteQuery({ queryKey: ['users', 'list'], queryFn: ({ pageParam = 1 }) => api(`/api/users?page=${pageParam}&limit=20`), getNextPageParam: (lastPage, pages) => { if (lastPage.data.length < 20) return undefined return pages.length + 1 }, initialPageParam: 1, }) } ``` ## Mutations ### Basic Mutation ```typescript // hooks/useUpdateProfile.ts import { useMutation, useQueryClient } from '@tanstack/react-query' import { usersApi } from '@/lib/api/users' export function useUpdateProfile() { const queryClient = useQueryClient() return useMutation({ mutationFn: ({ id, data }: { id: string; data: Partial }) => usersApi.update(id, data), onSuccess: (response, { id }) => { // Update cache queryClient.setQueryData(['users', id], response) queryClient.invalidateQueries({ queryKey: ['users', 'me'] }) }, }) } ``` ### Optimistic Update ```typescript export function useToggleFavorite() { const queryClient = useQueryClient() return useMutation({ mutationFn: (itemId: string) => api(`/api/favorites/${itemId}`, { method: 'POST' }), onMutate: async (itemId) => { // Cancel outgoing refetches await queryClient.cancelQueries({ queryKey: ['items', itemId] }) // Snapshot previous value const previousItem = queryClient.getQueryData(['items', itemId]) // Optimistically update queryClient.setQueryData(['items', itemId], (old: any) => ({ ...old, isFavorite: !old.isFavorite, })) return { previousItem } }, onError: (err, itemId, context) => { // Rollback on error queryClient.setQueryData(['items', itemId], context?.previousItem) }, onSettled: (data, error, itemId) => { // Refetch to ensure sync queryClient.invalidateQueries({ queryKey: ['items', itemId] }) }, }) } ``` ## Authentication Flow ### Login ```typescript // hooks/useAuth.ts import { useMutation, useQueryClient } from '@tanstack/react-query' import * as SecureStore from 'expo-secure-store' import { router } from 'expo-router' import { api } from '@/lib/api' interface LoginInput { email: string password: string } export function useLogin() { const queryClient = useQueryClient() return useMutation({ mutationFn: (input: LoginInput) => api<{ token: string; user: User }>('/api/auth/login', { method: 'POST', body: JSON.stringify(input), requireAuth: false, }), onSuccess: async (response) => { await SecureStore.setItemAsync('auth_token', response.token) queryClient.setQueryData(['users', 'me'], { data: response.user }) router.replace('/(tabs)') }, }) } export function useLogout() { const queryClient = useQueryClient() return useMutation({ mutationFn: async () => { await SecureStore.deleteItemAsync('auth_token') }, onSuccess: () => { queryClient.clear() router.replace('/(auth)/login') }, }) } ``` ### Auth State Check ```typescript // hooks/useAuthState.ts import { useQuery } from '@tanstack/react-query' import * as SecureStore from 'expo-secure-store' export function useAuthState() { return useQuery({ queryKey: ['auth', 'state'], queryFn: async () => { const token = await SecureStore.getItemAsync('auth_token') return { isAuthenticated: !!token } }, staleTime: Infinity, }) } ``` ## Error Handling ### Global Error Handler ```typescript // In query client setup const queryClient = new QueryClient({ defaultOptions: { mutations: { onError: (error) => { if (error instanceof ApiError) { if (error.status === 401) { // Handle unauthorized - redirect to login SecureStore.deleteItemAsync('auth_token') router.replace('/(auth)/login') return } } // Show toast or alert Alert.alert('Error', error.message) }, }, }, }) ``` ### Per-Query Error Handling ```tsx function ProfileScreen() { const { data, error, isLoading, refetch } = useCurrentUser() if (isLoading) return if (error) { return ( ) } return } ``` ## Offline Support ### Query Persistence ```typescript // lib/query-client.ts import { createAsyncStoragePersister } from '@tanstack/query-async-storage-persister' import AsyncStorage from '@react-native-async-storage/async-storage' import { persistQueryClient } from '@tanstack/react-query-persist-client' const asyncStoragePersister = createAsyncStoragePersister({ storage: AsyncStorage, }) persistQueryClient({ queryClient, persister: asyncStoragePersister, }) ``` ### Network Status ```typescript // hooks/useNetworkStatus.ts import { useEffect, useState } from 'react' import NetInfo from '@react-native-community/netinfo' import { onlineManager } from '@tanstack/react-query' export function useNetworkStatus() { const [isOnline, setIsOnline] = useState(true) useEffect(() => { return NetInfo.addEventListener((state) => { const online = !!state.isConnected setIsOnline(online) onlineManager.setOnline(online) }) }, []) return isOnline } ``` ## Usage in Components ```tsx // screens/ProfileScreen.tsx import { useCurrentUser, useUpdateProfile } from '@/hooks/useUser' export function ProfileScreen() { const { data: user, isLoading } = useCurrentUser() const updateProfile = useUpdateProfile() const handleSave = (formData: Partial) => { updateProfile.mutate( { id: user.id, data: formData }, { onSuccess: () => { Alert.alert('Success', 'Profile updated!') }, } ) } if (isLoading) return return ( ) } ``` ## Checklist Before shipping: - [ ] Auth token stored in SecureStore (not AsyncStorage) - [ ] 401 responses trigger logout/re-auth - [ ] Loading states shown during fetches - [ ] Error states with retry options - [ ] Optimistic updates where appropriate - [ ] Offline support if required - [ ] Request timeouts configured - [ ] No sensitive data logged