> ## 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.

# 15. React Query & Data Fetching

> Master server state management with React Query for efficient data fetching

<Frame>
  <img src="https://mintlify.s3.us-west-1.amazonaws.com/devweeekends/images/courses/react-native-crash-course/react-query.svg" alt="React Query & Data Fetching" />
</Frame>

## Module Overview

<Info>
  **Estimated Time**: 4 hours | **Difficulty**: Intermediate | **Prerequisites**: Networking, TypeScript
</Info>

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

```bash theme={null}
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.

```tsx theme={null}
// 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);
```

```tsx theme={null}
// 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

```tsx theme={null}
// 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

```tsx theme={null}
// 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

```tsx theme={null}
// 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

```tsx theme={null}
// 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

```tsx theme={null}
// 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

```tsx theme={null}
// 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.

```tsx theme={null}
// 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

```tsx theme={null}
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

```tsx theme={null}
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

```tsx theme={null}
// 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

```tsx theme={null}
// 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

```tsx theme={null}
// 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

```tsx theme={null}
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

```tsx theme={null}
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.

| Strategy         | staleTime   | Best For                                           | Trade-off                                                                        |
| ---------------- | ----------- | -------------------------------------------------- | -------------------------------------------------------------------------------- |
| **Always fresh** | 0 (default) | Chat messages, live scores, stock prices           | High network usage, but users never see stale data                               |
| **Short cache**  | 1-5 min     | User profiles, notification counts, dashboards     | Good balance for data that changes occasionally                                  |
| **Long cache**   | 15-60 min   | Reference data, settings, feature flags            | Minimal network usage, but users may see outdated data                           |
| **Manual only**  | `Infinity`  | Static content, app configuration, onboarding data | Zero 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:

| Capability               | React Query                      | SWR                          | Redux Toolkit Query   | Apollo Client           |
| ------------------------ | -------------------------------- | ---------------------------- | --------------------- | ----------------------- |
| **Caching**              | Automatic, configurable          | Automatic, simpler API       | Automatic, tag-based  | Normalized cache        |
| **Optimistic updates**   | Built-in lifecycle hooks         | Manual                       | Built-in              | Built-in                |
| **Infinite scroll**      | `useInfiniteQuery`               | Manual cursor tracking       | Manual                | `fetchMore`             |
| **Offline support**      | Via persistors and Expo plugin   | Manual                       | Manual                | apollo3-cache-persist   |
| **Bundle size**          | \~13 KB gzip                     | \~4 KB gzip                  | Part of RTK (\~11 KB) | \~33 KB gzip            |
| **React Native support** | Excellent (official Expo plugin) | Good (no official RN plugin) | Good                  | Good                    |
| **DevTools**             | Excellent (Flipper plugin)       | Limited on RN                | Redux DevTools        | Apollo DevTools         |
| **Learning curve**       | Moderate                         | Low                          | High (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.

```tsx theme={null}
// 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.

```tsx theme={null}
// 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:

```tsx theme={null}
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.

```tsx theme={null}
// 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

<CardGroup cols={2}>
  <Card title="Set Proper Stale Times" icon="clock">
    Don't refetch too often - configure staleTime based on data volatility
  </Card>

  <Card title="Use Optimistic Updates" icon="bolt">
    Improve UX by updating UI before server responds
  </Card>

  <Card title="Handle Errors Gracefully" icon="shield">
    Implement proper error boundaries and retry logic
  </Card>

  <Card title="Invalidate After Mutations" icon="refresh">
    Always invalidate queries after mutations that change data
  </Card>
</CardGroup>

### 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:

```tsx theme={null}
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:

```tsx theme={null}
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

<Card title="Module 16: Local Storage & Databases" icon="arrow-right" href="/courses/react-native-crash-course/16-local-storage">
  Learn to persist data locally with AsyncStorage and SQLite
</Card>
