Skip to main content
React Query & Data Fetching

Module Overview

Estimated Time: 4 hours | Difficulty: Intermediate | Prerequisites: Networking, TypeScript
React Query (TanStack Query) revolutionizes server state management in React Native applications. This module covers caching, background updates, optimistic updates, and building a robust data fetching layer. What You’ll Learn:
  • 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