Skip to main content

Documentation Index

Fetch the complete documentation index at: https://resources.devweekends.com/llms.txt

Use this file to discover all available pages before exploring further.

React Query & Data Fetching

Module Overview

Estimated Time: 4 hours | Difficulty: Intermediate | Prerequisites: Networking, TypeScript
React Query (TanStack Query) solves a problem that every mobile developer eventually hits: server state is fundamentally different from client state, and trying to manage both with the same tool (Redux, Zustand, or Context) leads to a tangled mess of loading flags, stale data, and manual cache invalidation. Server state is data you do not own — it lives on a remote server, can be changed by other users at any time, and becomes stale the moment you fetch it. React Query treats this as a caching problem: it fetches data, caches it, keeps it fresh in the background, and gives you simple hooks to read the cache. This module covers caching strategies, 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

The two most important settings are staleTime (how long cached data is considered fresh) and cacheTime (how long unused data stays in memory). Setting these correctly has a direct impact on your app’s battery consumption and perceived performance — too low and you waste bandwidth on redundant requests, too high and users see outdated information.
// src/api/queryClient.ts
import { QueryClient } from '@tanstack/react-query';
import { ExpoQueryClient } from '@tanstack/react-query-expo';

export const queryClient = new QueryClient({
  defaultOptions: {
    queries: {
      // Data is "fresh" for 5 minutes -- no refetch during this window.
      // On mobile, this is critical for reducing unnecessary network calls
      // when users navigate back and forth between screens.
      staleTime: 5 * 60 * 1000,
      // Unused data lives in memory for 30 minutes before garbage collection.
      // This means navigating to a screen you visited 20 minutes ago loads instantly.
      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

Mutations are for operations that change data on the server — creating, updating, or deleting records. While queries are read-only and can be freely cached, retried, and deduplicated, mutations are write operations with side effects. React Query gives you a structured lifecycle (onMutate, onError, onSettled) that makes implementing optimistic updates and rollbacks straightforward.

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 scrolling is the dominant pattern for mobile lists — users expect to scroll continuously and see new content loaded automatically, rather than tapping “Page 2” buttons. React Query’s useInfiniteQuery manages this seamlessly: it tracks page cursors, accumulates results across pages, and provides fetchNextPage / hasNextPage for your scroll handler.

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

Dependent queries let you chain data fetching — “fetch X, then use X’s result to fetch Y.” The enabled option is the key: when set to false, the query sits idle. When the dependency resolves and enabled becomes true, the query fires automatically.
// Fetch user data, then fetch their posts only after the user loads.
// This avoids making the posts request with an undefined userId.
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, // Query stays disabled until user data is available
  });
}

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

Optimistic updates are the secret to making your app feel instant. The idea: when a user performs an action (like toggling a status), immediately update the UI as if the server already confirmed it, then sync with the server in the background. If the server rejects the change, roll back to the previous state. This pattern is especially important on mobile where network latency is unpredictable. A 300ms delay that is barely noticeable on desktop WiFi feels sluggish on a cellular connection.

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] });
    },
  });
}

Choosing the Right Data Fetching Strategy

Not every screen needs the same fetching approach. The decision depends on how frequently the data changes, how critical freshness is to the user experience, and how expensive the API call is.
StrategystaleTimeBest ForTrade-off
Always fresh0 (default)Chat messages, live scores, stock pricesHigh network usage, but users never see stale data
Short cache1-5 minUser profiles, notification counts, dashboardsGood balance for data that changes occasionally
Long cache15-60 minReference data, settings, feature flagsMinimal network usage, but users may see outdated data
Manual onlyInfinityStatic content, app configuration, onboarding dataZero automatic refetches — you control updates entirely via invalidateQueries
Decision framework:
  1. How often does this data change on the server? If the answer is “rarely,” set a long staleTime. If “constantly,” keep it short or use WebSocket subscriptions instead of polling.
  2. What happens if the user sees stale data? For a social feed, stale by 30 seconds is fine. For an account balance, stale by 30 seconds could cause a failed transaction.
  3. How expensive is the API call? If the endpoint is slow or rate-limited, cache aggressively and prefetch on navigation intent rather than on mount.
  4. Is the user on mobile data? This is the argument for longer staleTime defaults in React Native versus web apps. Every unnecessary refetch costs battery and cellular data.

React Query vs. Alternatives

A common question is when React Query is the right tool versus alternatives. Here is how they compare for mobile data fetching:
CapabilityReact QuerySWRRedux Toolkit QueryApollo Client
CachingAutomatic, configurableAutomatic, simpler APIAutomatic, tag-basedNormalized cache
Optimistic updatesBuilt-in lifecycle hooksManualBuilt-inBuilt-in
Infinite scrolluseInfiniteQueryManual cursor trackingManualfetchMore
Offline supportVia persistors and Expo pluginManualManualapollo3-cache-persist
Bundle size~13 KB gzip~4 KB gzipPart of RTK (~11 KB)~33 KB gzip
React Native supportExcellent (official Expo plugin)Good (no official RN plugin)GoodGood
DevToolsExcellent (Flipper plugin)Limited on RNRedux DevToolsApollo DevTools
Learning curveModerateLowHigh (requires Redux)High (requires GraphQL)
When to use React Query: REST or GraphQL APIs, teams that want powerful caching without Redux boilerplate, apps where offline support and background refetching matter. When to consider alternatives: SWR if you want minimal bundle size and simpler API. Apollo Client if your backend is exclusively GraphQL and you need normalized caching. RTK Query if your app already uses Redux for client state.

Edge Cases and Gotchas

Race Conditions with Dependent Queries

When a parent query’s data changes (e.g., user switches accounts), dependent queries still hold cached data from the previous account. If you do not clear the cache on account switch, a user could briefly see another user’s data.
// Always reset the query cache on account/user switch
const handleAccountSwitch = async (newAccountId: string) => {
  // Remove all cached data before switching
  queryClient.removeQueries();
  // Now switch accounts
  await switchAccount(newAccountId);
};

Stale Closures in Mutation Callbacks

A subtle bug: if your onSuccess callback references component state via a closure, it captures the state value at the time the mutation was initiated, not when it completes. For mutations that take several seconds, the state may have changed.
// Problematic: `selectedFilter` might be stale when onSuccess runs
const createTask = useMutation({
  mutationFn: createTaskApi,
  onSuccess: () => {
    // This invalidates based on the filter value from when mutate() was called,
    // not when the server responded. If the user changed tabs, wrong query is invalidated.
    queryClient.invalidateQueries({ queryKey: ['tasks', selectedFilter] });
  },
});

// Better: invalidate broadly, or read current state inside the callback
const createTask = useMutation({
  mutationFn: createTaskApi,
  onSuccess: () => {
    // Invalidate all task queries regardless of filter
    queryClient.invalidateQueries({ queryKey: ['tasks'] });
  },
});

Infinite Query Memory Growth

useInfiniteQuery accumulates all fetched pages in memory. For feeds with hundreds of pages, this can consume significant memory on low-end devices. React Query v5 introduced maxPages to cap this:
useInfiniteQuery({
  queryKey: ['feed'],
  queryFn: fetchFeedPage,
  getNextPageParam: (lastPage) => lastPage.nextCursor,
  // Keep at most 5 pages in memory. Scrolling back past this
  // re-fetches from the server.
  maxPages: 5,
  initialPageParam: 0,
});

Network Reconnection Thundering Herd

When a mobile device transitions from airplane mode or a tunnel back to connectivity, React Query’s refetchOnReconnect fires for every stale query simultaneously. On an app with 50+ query keys, this can overwhelm your API.
// Stagger refetches on reconnect instead of firing all at once
const queryClient = new QueryClient({
  defaultOptions: {
    queries: {
      refetchOnReconnect: false, // Disable automatic reconnect refetch
    },
  },
});

// Manually refetch in priority order when connectivity returns
NetInfo.addEventListener((state) => {
  if (state.isConnected) {
    // Refetch critical data first
    queryClient.invalidateQueries({ queryKey: ['auth'] });
    // Delay non-critical refetches
    setTimeout(() => {
      queryClient.invalidateQueries({ queryKey: ['feed'] });
    }, 2000);
  }
});

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

Additional Practices for Production

  • Use query key factories. Instead of spreading string arrays across your codebase, centralize query keys in a factory object. This prevents typos, makes invalidation patterns discoverable, and keeps your code DRY:
export const queryKeys = {
  users: {
    all: ['users'] as const,
    detail: (id: string) => ['users', id] as const,
    posts: (id: string) => ['users', id, 'posts'] as const,
  },
  projects: {
    all: ['projects'] as const,
    detail: (id: string) => ['projects', id] as const,
    tasks: (id: string) => ['projects', id, 'tasks'] as const,
  },
};
  • Separate query hooks from components. Every useQuery call should live in its own custom hook file, not inline in a component. This makes queries reusable, testable in isolation, and keeps components focused on rendering.
  • Set retry: false for mutations. Queries are safe to retry because they are read-only. Mutations are not — retrying a POST /orders could create duplicate orders. Set retry: false or retry: 1 for mutations, and use idempotency keys on the server side.
  • Use placeholderData instead of separate loading states for detail screens. When navigating from a list to a detail screen, seed the detail query with the list item data so the screen renders instantly while the full data loads in the background:
export function useUser(userId: string) {
  const queryClient = useQueryClient();
  
  return useQuery({
    queryKey: queryKeys.users.detail(userId),
    queryFn: () => fetchUser(userId),
    placeholderData: () => {
      // Try to use data from the users list as placeholder
      const usersData = queryClient.getQueryData<User[]>(queryKeys.users.all);
      return usersData?.find((user) => user.id === userId);
    },
  });
}

Next Steps

Module 16: Local Storage & Databases

Learn to persist data locally with AsyncStorage and SQLite