React Native Data Management
API integration, server state management with React Query, offline storage, and data synchronization patterns.
Data management in React Native applications involves three distinct layers: server state (data owned by the backend), client state (UI state like form inputs, modal visibility), and persistent storage (cached data, user preferences). Conflating these responsibilities leads to bugs, stale data, and complex synchronization logic.
React Query revolutionizes server state management by treating it as asynchronous, cached data that can become stale. Traditional approaches store API responses in Redux/Zustand, requiring manual cache invalidation, loading states, and error handling. React Query automates this: it fetches data, caches it, manages loading/error states, refetches when stale, and provides optimistic updates. For banking apps, this means transaction lists automatically refresh in the background while showing cached data immediately, providing instant perceived performance.
The offline-first strategy is critical for banking apps used in areas with poor connectivity (subway commutes, rural areas). The pattern: show cached data immediately, fetch fresh data in the background, update UI when fresh data arrives. If the network is unavailable, users still see their last known account balance and transaction history. When they initiate a payment, it's queued locally and syncs when connectivity returns. This requires careful state management: React Query for server-owned data, AsyncStorage for persistence, and careful mutation queuing for offline writes. See React State Management for patterns on separating server and client state concerns.
Data Architecture
API Client Setup
Axios provides a higher-level API than fetch with automatic JSON transformation, request/response interceptors, and better error handling. For banking apps, interceptors are essential for cross-cutting concerns: adding authentication tokens to every request, refreshing expired tokens automatically, and handling global error scenarios (401 Unauthorized, 503 Service Unavailable).
Request interceptors run before every request, allowing you to modify headers, add authentication, or log requests. The async token retrieval from secure storage happens here rather than in every API function, centralizing authentication logic. This pattern ensures no API call can accidentally skip authentication.
Response interceptors process all responses before they reach your code. The 401 error handling is critical: when an auth token expires (common after 15-60 minutes), the interceptor catches it globally, logs out the user, and redirects to login. Without this, every API function would need identical error handling code. More sophisticated implementations automatically refresh tokens and retry the failed request, providing seamless re-authentication without user disruption.
The timeout setting prevents requests from hanging indefinitely on poor connections. Banking apps should timeout aggressively (10-30 seconds) to provide quick failure feedback rather than leaving users waiting. Network failures trigger error states where cached data can be shown with a "working offline" indicator.
Learn more about API design patterns in our API Guidelines and Spring Boot integration in Spring Boot API Design.
// services/api/client.ts
import axios from 'axios';
import { getSecureToken } from '../storage/secureStorage';
import { ENV } from '../../config/env';
/**
* Axios client with authentication and error handling
*/
export const apiClient = axios.create({
baseURL: ENV.API_URL,
timeout: ENV.API_TIMEOUT,
headers: {
'Content-Type': 'application/json',
},
});
// Request interceptor: Add auth token to every request
apiClient.interceptors.request.use(
async (config) => {
const token = await getSecureToken();
if (token) {
config.headers.Authorization = `Bearer ${token}`;
}
return config;
},
(error) => Promise.reject(error)
);
// Response interceptor: Handle global error scenarios
apiClient.interceptors.response.use(
(response) => response,
async (error) => {
if (error.response?.status === 401) {
// Token expired - logout user and redirect to login
await logout();
}
return Promise.reject(error);
}
);
React Query for Server State
// hooks/usePayments.ts
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { apiClient } from '../services/api/client';
import type { Payment, CreatePaymentRequest } from '../types/payment';
/**
* Fetch all payments with caching
*/
export function usePayments() {
return useQuery({
queryKey: ['payments'],
queryFn: async () => {
const { data } = await apiClient.get<Payment[]>('/payments');
return data;
},
staleTime: 30000, // Consider fresh for 30 seconds
gcTime: 300000, // Cache for 5 minutes
});
}
/**
* Fetch single payment by ID
*/
export function usePayment(id: string) {
return useQuery({
queryKey: ['payments', id],
queryFn: async () => {
const { data } = await apiClient.get<Payment>(`/payments/${id}`);
return data;
},
enabled: !!id, // Only run if ID exists
});
}
/**
* Create payment mutation
*/
export function useCreatePayment() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async (request: CreatePaymentRequest) => {
const { data } = await apiClient.post<Payment>('/payments', request);
return data;
},
onSuccess: (newPayment) => {
// Optimistic update: Add to cache immediately
queryClient.setQueryData<Payment[]>(['payments'], (old) =>
old ? [newPayment, ...old] : [newPayment]
);
// Invalidate to refetch from server
queryClient.invalidateQueries({ queryKey: ['payments'] });
},
});
}
// Usage in component
export default function CreatePaymentScreen() {
const { mutate: createPayment, isPending, isError } = useCreatePayment();
const handleSubmit = (values: CreatePaymentRequest) => {
createPayment(values, {
onSuccess: () => {
navigation.navigate('PaymentList');
},
onError: (error) => {
Alert.alert('Error', error.message);
},
});
};
return (
<PaymentForm onSubmit={handleSubmit} loading={isPending} />
);
}
Offline Storage with AsyncStorage
// services/storage/localStorage.ts
import AsyncStorage from '@react-native-async-storage/async-storage';
/**
* Type-safe local storage wrapper
*/
export const localStorage = {
async setItem<T>(key: string, value: T): Promise<void> {
try {
const jsonValue = JSON.stringify(value);
await AsyncStorage.setItem(key, jsonValue);
} catch (error) {
console.error(`Error saving ${key}:`, error);
throw error;
}
},
async getItem<T>(key: string): Promise<T | null> {
try {
const jsonValue = await AsyncStorage.getItem(key);
return jsonValue != null ? JSON.parse(jsonValue) : null;
} catch (error) {
console.error(`Error reading ${key}:`, error);
return null;
}
},
async removeItem(key: string): Promise<void> {
try {
await AsyncStorage.removeItem(key);
} catch (error) {
console.error(`Error removing ${key}:`, error);
throw error;
}
},
async clear(): Promise<void> {
try {
await AsyncStorage.clear();
} catch (error) {
console.error('Error clearing storage:', error);
throw error;
}
},
};
// Usage
import { localStorage } from '../services/storage/localStorage';
// Save user preferences
await localStorage.setItem('user-preferences', {
theme: 'dark',
notifications: true,
});
// Read user preferences
const prefs = await localStorage.getItem<UserPreferences>('user-preferences');
Offline-First Strategy
State Management with Zustand
// store/authStore.ts
import { create } from 'zustand';
import { persist, createJSONStorage } from 'zustand/middleware';
import AsyncStorage from '@react-native-async-storage/async-storage';
import type { User } from '../types/user';
interface AuthState {
user: User | null;
isAuthenticated: boolean;
setUser: (user: User | null) => void;
logout: () => void;
}
export const useAuthStore = create<AuthState>()(
persist(
(set) => ({
user: null,
isAuthenticated: false,
setUser: (user) => set({ user, isAuthenticated: !!user }),
logout: () => set({ user: null, isAuthenticated: false }),
}),
{
name: 'auth-storage',
storage: createJSONStorage(() => AsyncStorage),
}
)
);
// Usage in component
export default function ProfileScreen() {
const { user, logout } = useAuthStore();
return (
<View>
<Text>Welcome, {user?.name}</Text>
<Button title="Logout" onPress={logout} />
</View>
);
}
Network Status Handling
// hooks/useNetworkStatus.ts
import { useEffect, useState } from 'react';
import NetInfo from '@react-native-community/netinfo';
export function useNetworkStatus() {
const [isConnected, setIsConnected] = useState<boolean | null>(null);
const [isInternetReachable, setIsInternetReachable] = useState<boolean | null>(null);
useEffect(() => {
const unsubscribe = NetInfo.addEventListener((state) => {
setIsConnected(state.isConnected);
setIsInternetReachable(state.isInternetReachable);
});
return () => unsubscribe();
}, []);
return {
isConnected,
isInternetReachable,
isOffline: isConnected === false || isInternetReachable === false,
};
}
// Usage
export default function App() {
const { isOffline } = useNetworkStatus();
return (
<>
{isOffline && (
<View style={styles.offlineBanner}>
<Text style={styles.offlineText}>You are offline</Text>
</View>
)}
<AppNavigator />
</>
);
}
Common Mistakes
Not Handling Loading/Error States
// BAD: No loading or error handling
const { data } = usePayments();
return <FlatList data={data} />;
// GOOD: Proper state handling
const { data, isLoading, isError, error } = usePayments();
if (isLoading) return <LoadingSpinner />;
if (isError) return <ErrorView error={error} />;
if (!data) return <EmptyState />;
return <FlatList data={data} />;
Storing Sensitive Data in AsyncStorage
// BAD: Tokens in AsyncStorage (not encrypted)
await AsyncStorage.setItem('token', authToken);
// GOOD: Use Keychain for sensitive data
import * as Keychain from 'react-native-keychain';
await Keychain.setGenericPassword('token', authToken);
Code Review Checklist
API Integration
- API client configured with base URL and timeout
- Auth interceptor adds token to requests
- Error interceptor handles 401/403/500 errors
- TypeScript types for all API responses
- Loading states handled in all data fetching
React Query
- Query keys are unique and descriptive
- staleTime configured appropriately
- Mutations invalidate related queries
- Optimistic updates for instant UI feedback
- Error handling with onError callbacks
Offline Support
- AsyncStorage for non-sensitive data only
- Keychain for tokens and sensitive data
- Network status monitored with NetInfo
- Offline banner shown when disconnected
- Failed requests queued for retry when online
State Management
- Server state managed by React Query
- Client state managed by Zustand/Context
- State persistence configured for relevant data
- No state duplication between React Query and local state
- State updates are immutable
Further Reading
React Native Framework Guidelines
- React Native Overview - Project setup and structure
- React Native Security - Secure storage and API security
- React Native UI - Displaying data in components
- React Native Performance - Data layer optimization
Cross-Platform Guidelines
- React State Management - Zustand and React Query patterns
- API Design - RESTful API principles
- API Contracts - API contract design
- OpenAPI Frontend Integration - Type-safe client generation
- Database Design - Local storage patterns
- Caching - Caching strategies
External Resources
Summary
Key Takeaways
- React Query - Server state with automatic caching and refetching
- Axios client - Centralized API config with interceptors
- AsyncStorage - Non-sensitive data only (preferences, cache)
- Keychain - Sensitive data (tokens, credentials)
- Offline-first - Show cached data, sync in background
- Network monitoring - NetInfo for connection status
- Optimistic updates - Instant UI feedback
- Error handling - Loading, error, empty states
- Type safety - TypeScript for all API responses
- State separation - Server state (React Query) vs client state (Zustand)
Next Steps: Review React Native Security for secure storage patterns and biometric authentication.