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
npm install @tanstack/react-query
npx expo install @tanstack/react-query-expo
Configuration
// 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);
// 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
// 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
// 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
// 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
// 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
// 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
// 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
// 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
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
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
// 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
// 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
// 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
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
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