Module Overview
Estimated Time: 4 hours | Difficulty: Intermediate | Prerequisites: Networking, TypeScript
- React Query setup and configuration
- Query hooks and mutations
- Caching strategies
- Optimistic updates
- Pagination and infinite queries
- Error handling and retry logic
React Query Setup
Installation
Copy
npm install @tanstack/react-query
npx expo install @tanstack/react-query-expo
Configuration
Copy
// src/api/queryClient.ts
import { QueryClient } from '@tanstack/react-query';
import { ExpoQueryClient } from '@tanstack/react-query-expo';
export const queryClient = new QueryClient({
defaultOptions: {
queries: {
// Cache data for 5 minutes
staleTime: 5 * 60 * 1000,
// Keep unused data in cache for 30 minutes
cacheTime: 30 * 60 * 1000,
// Retry failed requests 3 times
retry: 3,
// Don't refetch on window focus in production
refetchOnWindowFocus: __DEV__,
// Don't refetch on reconnect automatically
refetchOnReconnect: 'always',
},
mutations: {
// Retry mutations once
retry: 1,
},
},
});
export const expoQueryClient = new ExpoQueryClient(queryClient);
Copy
// providers/QueryProvider.tsx
import { QueryClientProvider } from '@tanstack/react-query';
import { ExpoQueryClientProvider } from '@tanstack/react-query-expo';
import { queryClient } from '@/api/queryClient';
export function QueryProvider({ children }: { children: React.ReactNode }) {
return (
<QueryClientProvider client={queryClient}>
<ExpoQueryClientProvider>
{children}
</ExpoQueryClientProvider>
</QueryClientProvider>
);
}
Data Fetching Hooks
Basic Query
Copy
// src/hooks/useUsers.ts
import { useQuery } from '@tanstack/react-query';
import { apiClient } from '@/services/api/client';
interface User {
id: string;
name: string;
email: string;
}
export function useUsers() {
return useQuery({
queryKey: ['users'],
queryFn: async (): Promise<User[]> => {
const response = await apiClient.get<User[]>('/users');
return response.data;
},
});
}
export function useUser(userId: string) {
return useQuery({
queryKey: ['users', userId],
queryFn: async (): Promise<User> => {
const response = await apiClient.get<User>(`/users/${userId}`);
return response.data;
},
// Don't run query if userId is not provided
enabled: !!userId,
});
}
Component Usage
Copy
// screens/UsersScreen.tsx
import { View, FlatList, Text, Pressable, StyleSheet, ActivityIndicator } from 'react-native';
import { useUsers } from '@/hooks/useUsers';
import { UserCard } from '@/components/UserCard';
import { ErrorView } from '@/components/ErrorView';
export function UsersScreen() {
const { data: users, isLoading, isError, error, refetch } = useUsers();
if (isLoading) {
return (
<View style={styles.centerContainer}>
<ActivityIndicator size="large" color="#3b82f6" />
</View>
);
}
if (isError) {
return (
<ErrorView
error={error as Error}
onRetry={() => refetch()}
/>
);
}
return (
<FlatList
data={users}
keyExtractor={(item) => item.id}
renderItem={({ item }) => <UserCard user={item} />}
contentContainerStyle={styles.list}
onRefresh={refetch}
refreshing={isLoading}
/>
);
}
const styles = StyleSheet.create({
list: {
padding: 16,
gap: 12,
},
centerContainer: {
flex: 1,
justifyContent: 'center',
alignItems: 'center',
},
});
Mutations
Basic Mutation
Copy
// src/hooks/useCreateUser.ts
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { apiClient } from '@/services/api/client';
import { showToast } from '@/utils/toast';
interface CreateUserData {
name: string;
email: string;
}
export function useCreateUser() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async (data: CreateUserData) => {
const response = await apiClient.post('/users', data);
return response.data;
},
onMutate: (newUser) => {
// Optimistic update - cancel outgoing refetches
queryClient.cancelQueries({ queryKey: ['users'] });
// Snapshot previous value
const previousUsers = queryClient.getQueryData(['users']);
// Optimistically update
queryClient.setQueryData(['users'], (old: CreateUserData[] = []) => [
...old,
{ id: 'temp-id', ...newUser },
]);
return { previousUsers };
},
onError: (error, newUser, context) => {
// Rollback to snapshot
if (context?.previousUsers) {
queryClient.setQueryData(['users'], context.previousUsers);
}
showToast({ type: 'error', message: 'Failed to create user' });
},
onSettled: () => {
// Always refetch after error or success
queryClient.invalidateQueries({ queryKey: ['users'] });
showToast({ type: 'success', message: 'User created successfully' });
},
});
}
Mutation with Loading State
Copy
// components/UserForm.tsx
import { useState } from 'react';
import { View, TextInput, Pressable, Text, StyleSheet } from 'react-native';
import { useCreateUser } from '@/hooks/useCreateUser';
export function UserForm() {
const [name, setName] = useState('');
const [email, setEmail] = useState('');
const createUser = useCreateUser();
const handleSubmit = () => {
if (!name || !email) return;
createUser.mutate(
{ name, email },
{
onSuccess: () => {
setName('');
setEmail('');
},
}
);
};
return (
<View style={styles.container}>
<TextInput
style={styles.input}
placeholder="Name"
value={name}
onChangeText={setName}
autoCapitalize="words"
/>
<TextInput
style={styles.input}
placeholder="Email"
value={email}
onChangeText={setEmail}
keyboardType="email-address"
autoCapitalize="none"
/>
<Pressable
style={[styles.button, createUser.isPending && styles.buttonDisabled]}
onPress={handleSubmit}
disabled={createUser.isPending}
>
<Text style={styles.buttonText}>
{createUser.isPending ? 'Creating...' : 'Create User'}
</Text>
</Pressable>
{createUser.isError && (
<Text style={styles.errorText}>
{(createUser.error as Error).message}
</Text>
)}
</View>
);
}
const styles = StyleSheet.create({
container: {
gap: 16,
},
input: {
borderWidth: 1,
borderColor: '#e5e7eb',
borderRadius: 8,
padding: 12,
fontSize: 16,
},
button: {
backgroundColor: '#3b82f6',
padding: 16,
borderRadius: 8,
alignItems: 'center',
},
buttonDisabled: {
backgroundColor: '#93c5fd',
},
buttonText: {
color: '#fff',
fontWeight: '600',
fontSize: 16,
},
errorText: {
color: '#ef4444',
fontSize: 14,
},
});
Pagination
Infinite Query
Copy
// src/hooks/useInfiniteUsers.ts
import { useInfiniteQuery } from '@tanstack/react-query';
import { apiClient } from '@/services/api/client';
interface UsersResponse {
data: User[];
nextPage: number | null;
total: number;
}
export function useInfiniteUsers(pageSize: number = 20) {
return useInfiniteQuery({
queryKey: ['users', 'infinite'],
queryFn: async ({ pageParam = 1 }): Promise<UsersResponse> => {
const response = await apiClient.get<UsersResponse>('/users', {
params: {
page: pageParam,
limit: pageSize,
},
});
return response.data;
},
getNextPageParam: (lastPage) => {
return lastPage.nextPage ?? undefined;
},
initialPageParam: 1,
});
}
Paginated List Component
Copy
// screens/InfiniteUsersScreen.tsx
import { FlatList, View, Text, Pressable, StyleSheet, ActivityIndicator } from 'react-native';
import { useInfiniteUsers } from '@/hooks/useInfiniteUsers';
export function InfiniteUsersScreen() {
const {
data,
fetchNextPage,
hasNextPage,
isFetchingNextPage,
isLoading,
isError,
} = useInfiniteUsers(20);
const allUsers = data?.pages.flatMap((page) => page.data) ?? [];
const loadMore = () => {
if (hasNextPage && !isFetchingNextPage) {
fetchNextPage();
}
};
if (isLoading) {
return <ActivityIndicator size="large" style={styles.center} />;
}
if (isError) {
return <Text style={styles.error}>Error loading users</Text>;
}
return (
<FlatList
data={allUsers}
keyExtractor={(item) => item.id}
renderItem={({ item }) => (
<View style={styles.userCard}>
<Text style={styles.userName}>{item.name}</Text>
<Text style={styles.userEmail}>{item.email}</Text>
</View>
)}
onEndReached={loadMore}
onEndReachedThreshold={0.5}
ListFooterComponent={
isFetchingNextPage ? (
<ActivityIndicator size="small" style={styles.loader} />
) : hasNextPage ? (
<Pressable style={styles.loadMoreButton} onPress={loadMore}>
<Text style={styles.loadMoreText}>Load More</Text>
</Pressable>
) : (
<Text style={styles.endText}>No more users</Text>
)
}
/>
);
}
const styles = StyleSheet.create({
center: {
flex: 1,
justifyContent: 'center',
},
error: {
color: '#ef4444',
padding: 16,
textAlign: 'center',
},
userCard: {
backgroundColor: '#fff',
padding: 16,
borderRadius: 8,
marginBottom: 8,
marginHorizontal: 16,
},
userName: {
fontSize: 16,
fontWeight: '600',
},
userEmail: {
fontSize: 14,
color: '#6b7280',
marginTop: 4,
},
loader: {
padding: 16,
},
loadMoreButton: {
padding: 16,
alignItems: 'center',
},
loadMoreText: {
color: '#3b82f6',
fontSize: 16,
fontWeight: '500',
},
endText: {
padding: 16,
textAlign: 'center',
color: '#9ca3af',
},
});
Advanced Patterns
Dependent Queries
Copy
// Fetch user data, then fetch their posts
function useUserPosts(userId: string) {
const { data: user } = useUser(userId);
return useQuery({
queryKey: ['users', userId, 'posts'],
queryFn: async () => {
const response = await apiClient.get(`/users/${userId}/posts`);
return response.data;
},
enabled: !!user, // Only run if user is loaded
});
}
Parallel Queries
Copy
function useDashboardData() {
const users = useQuery({ queryKey: ['users'] });
const posts = useQuery({ queryKey: ['posts'] });
const stats = useQuery({ queryKey: ['stats'] });
return {
users: users.data,
posts: posts.data,
stats: stats.data,
isLoading: users.isLoading || posts.isLoading || stats.isLoading,
isError: users.isError || posts.isError || stats.isError,
};
}
Dependent Queries with useQueries
Copy
function useUserActivities(userIds: string[]) {
const results = useQueries({
queries: userIds.map((id) => ({
queryKey: ['user', id, 'activities'],
queryFn: async () => {
const response = await apiClient.get(`/users/${id}/activities`);
return response.data;
},
staleTime: 10 * 60 * 1000, // 10 minutes
})),
});
return {
data: results.map((result) => result.data),
isLoading: results.some((r) => r.isLoading),
isError: results.some((r) => r.isError),
};
}
Cache Management
Prefetching
Copy
// Prefetch data on screen mount
function usePrefetchUserData(userId: string) {
const queryClient = useQueryClient();
useEffect(() => {
queryClient.prefetchQuery({
queryKey: ['users', userId],
queryFn: () => apiClient.get(`/users/${userId}`).then((r) => r.data),
});
}, [userId, queryClient]);
}
Manual Cache Updates
Copy
// Update cache directly
function useUpdateUserCache() {
const queryClient = useQueryClient();
const updateUser = (userId: string, updates: Partial<User>) => {
queryClient.setQueryData(['users', userId], (old: User | undefined) => {
if (!old) return old;
return { ...old, ...updates };
});
};
const invalidateUser = (userId: string) => {
queryClient.invalidateQueries({ queryKey: ['users', userId] });
};
return { updateUser, invalidateUser };
}
Error Handling
Global Error Handler
Copy
// src/api/queryClient.ts
export const queryClient = new QueryClient({
defaultOptions: {
queries: {
...defaultOptions.queries,
retry: (failureCount, error) => {
// Don't retry on 401, 403 errors
if (error instanceof Error && error.message.includes('401')) {
return false;
}
return failureCount < 3;
},
onError: (error) => {
// Log to error tracking service
console.error('Query error:', error);
// showToast({ type: 'error', message: 'Something went wrong' });
},
},
mutations: {
onError: (error) => {
// Handle mutation errors
console.error('Mutation error:', error);
},
},
},
});
Query Retry Logic
Copy
function useStableQuery() {
return useQuery({
queryKey: ['stable-data'],
queryFn: fetchStableData,
retry: 3,
retryDelay: (attemptIndex) => {
// Exponential backoff: 1s, 2s, 4s
return Math.min(1000 * 2 ** attemptIndex, 30000);
},
});
}
Optimistic Updates
Complete Optimistic Update Pattern
Copy
function useToggleUserStatus() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async ({ userId, isActive }: { userId: string; isActive: boolean }) => {
const response = await apiClient.patch(`/users/${userId}`, { isActive });
return response.data;
},
onMutate: async ({ userId, isActive }) => {
// Cancel outgoing refetches
await queryClient.cancelQueries({ queryKey: ['users'] });
await queryClient.cancelQueries({ queryKey: ['users', userId] });
// Snapshot previous values
const previousUsers = queryClient.getQueryData(['users']);
const previousUser = queryClient.getQueryData(['users', userId]);
// Optimistically update users list
if (previousUsers) {
queryClient.setQueryData(['users'], (old: User[]) =>
old.map((user) =>
user.id === userId ? { ...user, isActive } : user
)
);
}
// Optimistically update single user
if (previousUser) {
queryClient.setQueryData(['users', userId], (old: User) => ({
...old,
isActive,
}));
}
return { previousUsers, previousUser };
},
onError: (error, { userId }, context) => {
// Rollback on error
if (context?.previousUsers) {
queryClient.setQueryData(['users'], context.previousUsers);
}
if (context?.previousUser) {
queryClient.setQueryData(['users', userId], context.previousUser);
}
showToast({ type: 'error', message: 'Failed to update user status' });
},
onSettled: (data, error, { userId }) => {
// Always refetch to ensure sync
queryClient.invalidateQueries({ queryKey: ['users'] });
queryClient.invalidateQueries({ queryKey: ['users', userId] });
},
});
}
Best Practices
Set Proper Stale Times
Don’t refetch too often - configure staleTime based on data volatility
Use Optimistic Updates
Improve UX by updating UI before server responds
Handle Errors Gracefully
Implement proper error boundaries and retry logic
Invalidate After Mutations
Always invalidate queries after mutations that change data
Next Steps
Module 16: Local Storage & Databases
Learn to persist data locally with AsyncStorage and SQLite