Module 7: Data Management and Networking
Data Fetching Libraries
Let battle-tested libraries handle the hard parts of data management
🎯 Learning Objectives
- Understand why data fetching libraries exist and what problems they solve
- Set up and use TanStack Query (React Query) in a React Native app
- Implement automatic caching, background refetching, and stale-while-revalidate
- Handle mutations with automatic cache invalidation
- Build optimistic updates for instant UI feedback
- Implement infinite scroll with useInfiniteQuery
- Compare React Query with SWR and choose the right tool
Why Use a Data Fetching Library?
In the previous lesson, we built our own useFetch hook. It worked, but as your app grows, you'll encounter increasingly complex requirements that are tedious to implement yourself:
😫 Problems You'll Eventually Face
- Duplicate requests — Two components need the same data, both fetch it
- Stale data — User sees outdated information after navigating back
- Cache invalidation — After updating data, related queries show old values
- Loading states everywhere — Every component manages its own loading state
- Background updates — Data could be fresher, but you only fetch on mount
- Optimistic UI — User waits for server confirmation before seeing changes
- Pagination complexity — Infinite scroll with proper caching is hard
- Retry logic — Network errors need automatic retry with backoff
Data fetching libraries solve all of these problems with battle-tested solutions. The two most popular options are TanStack Query (formerly React Query) and SWR.
📖 The Core Insight
Data fetching libraries treat server state as fundamentally different from client state. Server state is data that lives on a remote server, can become stale, and needs to be synchronized. These libraries are purpose-built for managing this type of state.
Setting Up TanStack Query
TanStack Query (the new name for React Query) is the most popular and feature-rich option. Let's set it up in an Expo project.
Installation
npx expo install @tanstack/react-query
Provider Setup
Wrap your app with the QueryClientProvider:
// app/_layout.tsx
import { Stack } from 'expo-router';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
// Create a client outside of components to avoid recreation
const queryClient = new QueryClient({
defaultOptions: {
queries: {
// How long data is considered fresh (won't refetch)
staleTime: 1000 * 60 * 5, // 5 minutes
// How long inactive data stays in cache
gcTime: 1000 * 60 * 30, // 30 minutes (formerly cacheTime)
// Retry failed requests
retry: 2,
// Refetch when window regains focus (useful!)
refetchOnWindowFocus: true,
// Refetch when network reconnects
refetchOnReconnect: true,
},
},
});
export default function RootLayout() {
return (
<QueryClientProvider client={queryClient}>
<Stack />
</QueryClientProvider>
);
}
💡 What These Options Mean
staleTime: Data younger than this is "fresh" and won't trigger a refetch. Set to 0 (default) for always-stale data that refetches on every mount.
gcTime: How long to keep inactive (no active observers) data in memory before garbage collecting it.
refetchOnWindowFocus: In React Native, this triggers when the app comes to the foreground—great for keeping data fresh!
React Native Specific Setup
For React Native, you'll want to handle app state changes to trigger refetches when the app returns from background:
// app/_layout.tsx
import { useEffect } from 'react';
import { AppState, Platform } from 'react-native';
import { Stack } from 'expo-router';
import {
QueryClient,
QueryClientProvider,
focusManager,
onlineManager
} from '@tanstack/react-query';
import NetInfo from '@react-native-community/netinfo';
const queryClient = new QueryClient();
export default function RootLayout() {
useEffect(() => {
// Handle app state changes (foreground/background)
const subscription = AppState.addEventListener('change', (status) => {
if (Platform.OS !== 'web') {
focusManager.setFocused(status === 'active');
}
});
return () => subscription.remove();
}, []);
useEffect(() => {
// Handle online/offline status
return NetInfo.addEventListener((state) => {
onlineManager.setOnline(
state.isConnected != null &&
state.isConnected &&
Boolean(state.isInternetReachable)
);
});
}, []);
return (
<QueryClientProvider client={queryClient}>
<Stack />
</QueryClientProvider>
);
}
This setup ensures that when users return to your app or regain network connectivity, stale queries automatically refetch.
useQuery: Fetching Data
The useQuery hook is the foundation of TanStack Query. It takes a unique key and a function that returns a promise.
import { useQuery } from '@tanstack/react-query';
import { View, Text, FlatList, ActivityIndicator } from 'react-native';
interface User {
id: number;
name: string;
email: string;
}
// Define your fetch function separately for reusability
async function fetchUsers(): Promise<User[]> {
const response = await fetch('https://jsonplaceholder.typicode.com/users');
if (!response.ok) {
throw new Error('Failed to fetch users');
}
return response.json();
}
export default function UserList() {
const {
data: users,
isLoading,
isError,
error,
refetch,
isRefetching
} = useQuery({
queryKey: ['users'], // Unique identifier for this query
queryFn: fetchUsers, // Function that fetches the data
});
if (isLoading) {
return (
<View style={styles.centered}>
<ActivityIndicator size="large" />
</View>
);
}
if (isError) {
return (
<View style={styles.centered}>
<Text>Error: {error.message}</Text>
</View>
);
}
return (
<FlatList
data={users}
keyExtractor={(item) => item.id.toString()}
renderItem={({ item }) => (
<View style={styles.userCard}>
<Text style={styles.name}>{item.name}</Text>
<Text style={styles.email}>{item.email}</Text>
</View>
)}
onRefresh={refetch}
refreshing={isRefetching}
/>
);
}
Query Keys Are Crucial
Query keys uniquely identify your data. They determine caching, refetching, and sharing data between components.
// Simple key - fetches all users
useQuery({ queryKey: ['users'], queryFn: fetchUsers });
// Key with parameter - fetches specific user
useQuery({
queryKey: ['users', userId], // Different userId = different cache entry
queryFn: () => fetchUser(userId)
});
// Key with filters - fetches filtered data
useQuery({
queryKey: ['users', { status: 'active', page: 1 }],
queryFn: () => fetchUsers({ status: 'active', page: 1 })
});
// Nested keys for related data
useQuery({
queryKey: ['users', userId, 'posts'], // Posts for a specific user
queryFn: () => fetchUserPosts(userId)
});
⚠️ Key Matching Rules
Query keys are matched using deep equality. ['users', { page: 1 }] and ['users', { page: 2 }] are different queries with separate cache entries. This is powerful but requires careful key design.
Dependent Queries
Sometimes you need data from one query before you can run another. Use the enabled option:
function UserProfile({ userId }: { userId: string }) {
// First, fetch the user
const { data: user } = useQuery({
queryKey: ['users', userId],
queryFn: () => fetchUser(userId),
});
// Then, fetch their posts (only when we have the user)
const { data: posts } = useQuery({
queryKey: ['users', userId, 'posts'],
queryFn: () => fetchUserPosts(userId),
enabled: !!user, // Only run when user exists
});
// Even more dependent - fetch comments for first post
const { data: comments } = useQuery({
queryKey: ['posts', posts?.[0]?.id, 'comments'],
queryFn: () => fetchPostComments(posts![0].id),
enabled: !!posts && posts.length > 0, // Only when posts exist
});
// ...
}
sequenceDiagram
participant C as Component
participant Q1 as useQuery (user)
participant Q2 as useQuery (posts)
participant Q3 as useQuery (comments)
participant S as Server
C->>Q1: Mount with userId
Q1->>S: Fetch user
Note over Q2: enabled: false (waiting)
Note over Q3: enabled: false (waiting)
S-->>Q1: User data
Q1-->>C: user = {...}
Note over Q2: enabled: true (user exists)
Q2->>S: Fetch posts
S-->>Q2: Posts data
Q2-->>C: posts = [...]
Note over Q3: enabled: true (posts exist)
Q3->>S: Fetch comments
S-->>Q3: Comments data
Q3-->>C: comments = [...]
Caching and Stale Data
The real magic of TanStack Query is its caching system. Understanding how it works is key to building responsive apps.
The Stale-While-Revalidate Pattern
When you navigate to a screen that uses cached data:
- Immediately show cached data (even if stale)
- Background fetch fresh data
- Update the UI when fresh data arrives
This means users see something instantly while getting updated data moments later.
Controlling Staleness
// Data is fresh for 5 minutes — no refetch if accessed within that time
const { data } = useQuery({
queryKey: ['users'],
queryFn: fetchUsers,
staleTime: 1000 * 60 * 5, // 5 minutes
});
// Data is always stale — refetches on every mount (default behavior)
const { data } = useQuery({
queryKey: ['posts'],
queryFn: fetchPosts,
staleTime: 0, // Always stale (default)
});
// Data never goes stale — only fetches once ever
const { data } = useQuery({
queryKey: ['config'],
queryFn: fetchConfig,
staleTime: Infinity, // Never stale
});
Cache Lifecycle
stateDiagram-v2
[*] --> Fetching: Query mounted
Fetching --> Fresh: Success
Fetching --> Error: Failure
Fresh --> Stale: staleTime elapsed
Stale --> Fetching: Trigger (mount, focus, etc.)
Fresh --> Inactive: All observers unmount
Stale --> Inactive: All observers unmount
Inactive --> Fresh: New observer mounts
Inactive --> [*]: gcTime elapsed (garbage collected)
Error --> Fetching: Retry
Prefetching Data
You can prefetch data before the user navigates, making the next screen instant:
import { useQueryClient } from '@tanstack/react-query';
import { Pressable, Text } from 'react-native';
import { useRouter } from 'expo-router';
function UserListItem({ user }: { user: User }) {
const router = useRouter();
const queryClient = useQueryClient();
const handlePress = () => {
router.push(`/users/${user.id}`);
};
const handlePressIn = () => {
// Prefetch user details when finger touches down
// By the time they lift and navigate, data is cached!
queryClient.prefetchQuery({
queryKey: ['users', user.id],
queryFn: () => fetchUser(user.id),
});
};
return (
<Pressable
onPress={handlePress}
onPressIn={handlePressIn} // Prefetch on touch start
style={styles.item}
>
<Text>{user.name}</Text>
</Pressable>
);
}
✅ Pro Tip: Prefetch on Hover/Focus
For list items, prefetch on onPressIn. The ~100-200ms between touch start and navigation is often enough to complete the fetch, making the detail screen appear instant.
useMutation: Changing Data
While useQuery is for reading data, useMutation is for creating, updating, or deleting data. It handles the request lifecycle and provides hooks for cache invalidation.
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { View, TextInput, Pressable, Text, Alert } from 'react-native';
import { useState } from 'react';
interface CreatePostData {
title: string;
body: string;
}
async function createPost(data: CreatePostData) {
const response = await fetch('https://jsonplaceholder.typicode.com/posts', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data),
});
if (!response.ok) throw new Error('Failed to create post');
return response.json();
}
export default function CreatePostForm() {
const [title, setTitle] = useState('');
const [body, setBody] = useState('');
const queryClient = useQueryClient();
const mutation = useMutation({
mutationFn: createPost,
onSuccess: (newPost) => {
// Invalidate posts query to refetch the list
queryClient.invalidateQueries({ queryKey: ['posts'] });
// Clear the form
setTitle('');
setBody('');
Alert.alert('Success', 'Post created!');
},
onError: (error) => {
Alert.alert('Error', error.message);
},
});
const handleSubmit = () => {
if (!title.trim() || !body.trim()) {
Alert.alert('Error', 'Please fill in all fields');
return;
}
mutation.mutate({ title, body });
};
return (
<View style={styles.form}>
<TextInput
style={styles.input}
placeholder="Title"
value={title}
onChangeText={setTitle}
editable={!mutation.isPending}
/>
<TextInput
style={[styles.input, styles.bodyInput]}
placeholder="Body"
value={body}
onChangeText={setBody}
multiline
editable={!mutation.isPending}
/>
<Pressable
style={[
styles.button,
mutation.isPending && styles.buttonDisabled
]}
onPress={handleSubmit}
disabled={mutation.isPending}
>
<Text style={styles.buttonText}>
{mutation.isPending ? 'Creating...' : 'Create Post'}
</Text>
</Pressable>
</View>
);
}
Cache Invalidation Strategies
After a mutation, you need to update related cached data. There are several approaches:
const queryClient = useQueryClient();
// Strategy 1: Invalidate (refetch) related queries
queryClient.invalidateQueries({ queryKey: ['posts'] });
// Strategy 2: Invalidate with more specificity
queryClient.invalidateQueries({
queryKey: ['posts'],
exact: true // Only ['posts'], not ['posts', 1]
});
// Strategy 3: Update cache directly (no refetch)
queryClient.setQueryData(['posts'], (oldPosts: Post[]) => {
return [...oldPosts, newPost];
});
// Strategy 4: Remove from cache
queryClient.removeQueries({ queryKey: ['posts', deletedId] });
🔄 When to Invalidate vs Update Directly
| Approach | When to Use |
|---|---|
| invalidateQueries | When server might transform data or add computed fields |
| setQueryData | When you have all the data and want instant updates |
| Both | Update immediately, then invalidate to ensure consistency |
Optimistic Updates
Optimistic updates make your app feel instant by updating the UI before the server confirms the change. If the mutation fails, you roll back to the previous state.
import { useMutation, useQueryClient } from '@tanstack/react-query';
interface Post {
id: number;
title: string;
likes: number;
}
async function likePost(postId: number): Promise<Post> {
const response = await fetch(`/api/posts/${postId}/like`, {
method: 'POST',
});
if (!response.ok) throw new Error('Failed to like post');
return response.json();
}
function useLikePost() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: likePost,
// Called before the mutation function fires
onMutate: async (postId) => {
// Cancel any outgoing refetches so they don't overwrite our optimistic update
await queryClient.cancelQueries({ queryKey: ['posts', postId] });
// Snapshot the previous value
const previousPost = queryClient.getQueryData<Post>(['posts', postId]);
// Optimistically update the cache
queryClient.setQueryData<Post>(['posts', postId], (old) => {
if (!old) return old;
return { ...old, likes: old.likes + 1 };
});
// Return context with the snapshot
return { previousPost };
},
// Called if the mutation fails
onError: (err, postId, context) => {
// Roll back to the previous value
if (context?.previousPost) {
queryClient.setQueryData(['posts', postId], context.previousPost);
}
},
// Called after success or failure
onSettled: (data, error, postId) => {
// Always refetch to ensure we're in sync with the server
queryClient.invalidateQueries({ queryKey: ['posts', postId] });
},
});
}
// Usage in component
function PostCard({ post }: { post: Post }) {
const likeMutation = useLikePost();
return (
<View style={styles.card}>
<Text style={styles.title}>{post.title}</Text>
<Pressable
onPress={() => likeMutation.mutate(post.id)}
style={styles.likeButton}
>
<Text>❤️ {post.likes}</Text>
</Pressable>
</View>
);
}
⚠️ Optimistic Updates Require Careful Design
Only use optimistic updates when you're highly confident the mutation will succeed. For critical operations like payments or data deletion, wait for server confirmation before updating the UI.
Infinite Queries for Pagination
Infinite scroll is a common pattern in mobile apps. TanStack Query provides useInfiniteQuery specifically for paginated data that accumulates (like a social media feed).
import { useInfiniteQuery } from '@tanstack/react-query';
import {
View,
Text,
FlatList,
ActivityIndicator,
StyleSheet
} from 'react-native';
interface Post {
id: number;
title: string;
body: string;
}
interface PostsResponse {
posts: Post[];
nextCursor: number | null;
hasMore: boolean;
}
async function fetchPosts({ pageParam = 0 }): Promise<PostsResponse> {
const response = await fetch(
`https://api.example.com/posts?cursor=${pageParam}&limit=10`
);
if (!response.ok) throw new Error('Failed to fetch posts');
return response.json();
}
export default function InfiniteFeed() {
const {
data,
fetchNextPage,
hasNextPage,
isFetchingNextPage,
isLoading,
isError,
error,
refetch,
isRefetching,
} = useInfiniteQuery({
queryKey: ['posts', 'infinite'],
queryFn: fetchPosts,
initialPageParam: 0,
getNextPageParam: (lastPage) => {
// Return undefined to signal no more pages
return lastPage.hasMore ? lastPage.nextCursor : undefined;
},
});
// Flatten all pages into a single array
const allPosts = data?.pages.flatMap(page => page.posts) ?? [];
const loadMore = () => {
if (hasNextPage && !isFetchingNextPage) {
fetchNextPage();
}
};
if (isLoading) {
return (
<View style={styles.centered}>
<ActivityIndicator size="large" />
</View>
);
}
if (isError) {
return (
<View style={styles.centered}>
<Text>Error: {error.message}</Text>
</View>
);
}
return (
<FlatList
data={allPosts}
keyExtractor={(item) => item.id.toString()}
renderItem={({ item }) => (
<View style={styles.postCard}>
<Text style={styles.title}>{item.title}</Text>
<Text style={styles.body}>{item.body}</Text>
</View>
)}
onEndReached={loadMore}
onEndReachedThreshold={0.5}
ListFooterComponent={() =>
isFetchingNextPage ? (
<ActivityIndicator style={styles.footer} />
) : hasNextPage ? null : (
<Text style={styles.endText}>No more posts</Text>
)
}
onRefresh={refetch}
refreshing={isRefetching}
/>
);
}
const styles = StyleSheet.create({
centered: {
flex: 1,
justifyContent: 'center',
alignItems: 'center',
},
postCard: {
padding: 16,
backgroundColor: 'white',
marginHorizontal: 16,
marginVertical: 8,
borderRadius: 8,
shadowColor: '#000',
shadowOffset: { width: 0, height: 1 },
shadowOpacity: 0.1,
shadowRadius: 4,
elevation: 2,
},
title: {
fontSize: 18,
fontWeight: 'bold',
marginBottom: 8,
},
body: {
fontSize: 14,
color: '#666',
},
footer: {
padding: 20,
},
endText: {
textAlign: 'center',
padding: 20,
color: '#999',
},
});
Understanding getNextPageParam
The getNextPageParam function determines what to pass to the next fetch. Different APIs use different pagination styles:
// Cursor-based pagination (most common for infinite scroll)
getNextPageParam: (lastPage) => lastPage.nextCursor ?? undefined,
// Offset-based pagination
getNextPageParam: (lastPage, allPages) => {
const totalFetched = allPages.flatMap(p => p.items).length;
return totalFetched < lastPage.total ? totalFetched : undefined;
},
// Page number pagination
getNextPageParam: (lastPage, allPages) => {
return lastPage.hasMore ? allPages.length + 1 : undefined;
},
Bi-directional Infinite Scroll
For chat-like interfaces where you can scroll up to load older messages:
const {
data,
fetchNextPage, // Load newer messages
fetchPreviousPage, // Load older messages
hasPreviousPage,
isFetchingPreviousPage,
} = useInfiniteQuery({
queryKey: ['messages', chatId],
queryFn: ({ pageParam }) => fetchMessages(chatId, pageParam),
initialPageParam: { direction: 'initial' },
getNextPageParam: (lastPage) =>
lastPage.hasNewer ? { cursor: lastPage.newestId, direction: 'newer' } : undefined,
getPreviousPageParam: (firstPage) =>
firstPage.hasOlder ? { cursor: firstPage.oldestId, direction: 'older' } : undefined,
});
💡 FlatList Integration Tips
- Set
onEndReachedThresholdto 0.3-0.5 for smoother loading - Use
invertedprop for chat-style lists (newest at bottom) - Consider
maintainVisibleContentPositionfor bi-directional scroll - Flatten pages with
data.pages.flatMap()
SWR: The Alternative
SWR (Stale-While-Revalidate) is created by Vercel and offers a simpler API with fewer features. It's lighter-weight and might be a good choice for simpler apps.
Installation
npx expo install swr
Basic Usage
import useSWR from 'swr';
import { View, Text, ActivityIndicator } from 'react-native';
// SWR uses a global fetcher function
const fetcher = (url: string) => fetch(url).then(res => res.json());
function UserProfile({ userId }: { userId: string }) {
const { data, error, isLoading, mutate } = useSWR(
`https://api.example.com/users/${userId}`,
fetcher
);
if (isLoading) return <ActivityIndicator />;
if (error) return <Text>Error loading user</Text>;
return (
<View>
<Text>{data.name}</Text>
<Text>{data.email}</Text>
</View>
);
}
SWR Configuration
import { SWRConfig } from 'swr';
function App() {
return (
<SWRConfig
value={{
fetcher: (url) => fetch(url).then(res => res.json()),
refreshInterval: 30000, // Refetch every 30 seconds
revalidateOnFocus: true,
revalidateOnReconnect: true,
dedupingInterval: 2000, // Dedupe requests within 2 seconds
}}
>
<MyApp />
</SWRConfig>
);
}
Mutations in SWR
import useSWR, { useSWRConfig } from 'swr';
function UpdateUser({ userId }: { userId: string }) {
const { mutate } = useSWRConfig();
const { data: user } = useSWR(`/api/users/${userId}`);
const updateName = async (newName: string) => {
// Optimistic update
mutate(
`/api/users/${userId}`,
{ ...user, name: newName },
false // Don't revalidate yet
);
// Send to server
await fetch(`/api/users/${userId}`, {
method: 'PATCH',
body: JSON.stringify({ name: newName }),
});
// Revalidate to ensure consistency
mutate(`/api/users/${userId}`);
};
// ...
}
Key Differences from React Query
| Feature | TanStack Query | SWR |
|---|---|---|
| Bundle size | ~13kb gzipped | ~4kb gzipped |
| Query keys | Array-based | String-based (simpler) |
| Mutations | Dedicated useMutation hook | Manual with mutate() |
| Infinite queries | Built-in useInfiniteQuery | useSWRInfinite (simpler) |
| DevTools | Excellent (Flipper plugin) | Limited |
| Cache control | Fine-grained | Simpler |
| Learning curve | Steeper | Gentler |
Choosing the Right Library
Both libraries are excellent. Here's a decision framework:
flowchart TD
A[Starting a project] --> B{Complex data requirements?}
B -->|Yes| C{Need fine-grained cache control?}
B -->|No| D{Want simpler API?}
C -->|Yes| E[TanStack Query]
C -->|No| F{Need DevTools?}
D -->|Yes| G[SWR]
D -->|No| H{Bundle size critical?}
F -->|Yes| E
F -->|No| I{Heavy mutations?}
H -->|Yes| G
H -->|No| E
I -->|Yes| E
I -->|No| G
style E fill:#667eea,color:#fff
style G fill:#000,color:#fff
✅ Our Recommendation
For most React Native apps, TanStack Query is the better choice. The extra bundle size is negligible on mobile, and you'll likely need its advanced features as your app grows. The DevTools integration is invaluable for debugging.
Choose SWR if you're building a simple app with basic data fetching needs, or if you're already using it in a Next.js project and want consistency.
Quick Reference: Common Tasks
// ============= TanStack Query =============
// Setup
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
const queryClient = new QueryClient();
// Fetch data
const { data, isLoading, error } = useQuery({
queryKey: ['users'],
queryFn: fetchUsers,
});
// Mutate data
const mutation = useMutation({
mutationFn: createUser,
onSuccess: () => queryClient.invalidateQueries(['users']),
});
// Infinite scroll
const { data, fetchNextPage } = useInfiniteQuery({
queryKey: ['posts'],
queryFn: fetchPosts,
getNextPageParam: (lastPage) => lastPage.nextCursor,
});
// ============= SWR =============
// Setup
import { SWRConfig } from 'swr';
const fetcher = url => fetch(url).then(r => r.json());
// Fetch data
const { data, isLoading, error } = useSWR('/api/users', fetcher);
// Mutate data (manual)
const { mutate } = useSWRConfig();
await updateUser(data);
mutate('/api/users');
// Infinite scroll
const { data, size, setSize } = useSWRInfinite(
(index) => `/api/posts?page=${index}`,
fetcher
);
Hands-On Exercises
Exercise 1: Basic Query Setup
Set up TanStack Query in an Expo app and fetch a list of todos.
Requirements:
- Install and configure TanStack Query with a QueryClientProvider
- Create a component that fetches todos from
https://jsonplaceholder.typicode.com/todos - Display loading, error, and success states
- Add pull-to-refresh functionality
- Set staleTime to 1 minute
Show Hint
Remember to create the QueryClient outside of your component to avoid recreating it on every render. Use the isLoading, isError, and data properties from useQuery. The refetch function works great with FlatList's onRefresh.
Show Solution
// app/_layout.tsx
import { Stack } from 'expo-router';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: 1000 * 60, // 1 minute
},
},
});
export default function RootLayout() {
return (
<QueryClientProvider client={queryClient}>
<Stack />
</QueryClientProvider>
);
}
// app/index.tsx
import { useQuery } from '@tanstack/react-query';
import {
View,
Text,
FlatList,
StyleSheet,
ActivityIndicator
} from 'react-native';
interface Todo {
id: number;
title: string;
completed: boolean;
}
async function fetchTodos(): Promise<Todo[]> {
const response = await fetch(
'https://jsonplaceholder.typicode.com/todos?_limit=20'
);
if (!response.ok) throw new Error('Failed to fetch todos');
return response.json();
}
export default function TodoList() {
const {
data: todos,
isLoading,
isError,
error,
refetch,
isRefetching
} = useQuery({
queryKey: ['todos'],
queryFn: fetchTodos,
});
if (isLoading) {
return (
<View style={styles.centered}>
<ActivityIndicator size="large" color="#667eea" />
</View>
);
}
if (isError) {
return (
<View style={styles.centered}>
<Text style={styles.error}>Error: {error.message}</Text>
</View>
);
}
return (
<FlatList
data={todos}
keyExtractor={(item) => item.id.toString()}
contentContainerStyle={styles.list}
onRefresh={refetch}
refreshing={isRefetching}
renderItem={({ item }) => (
<View style={styles.todoItem}>
<Text style={[
styles.todoTitle,
item.completed && styles.completed
]}>
{item.completed ? '✓ ' : '○ '}{item.title}
</Text>
</View>
)}
/>
);
}
const styles = StyleSheet.create({
centered: {
flex: 1,
justifyContent: 'center',
alignItems: 'center',
},
list: {
padding: 16,
},
todoItem: {
padding: 16,
backgroundColor: 'white',
borderRadius: 8,
marginBottom: 8,
},
todoTitle: {
fontSize: 16,
},
completed: {
textDecorationLine: 'line-through',
color: '#999',
},
error: {
color: 'red',
},
});
Exercise 2: Mutations with Cache Invalidation
Add the ability to toggle todo completion status with optimistic updates.
Requirements:
- Create a mutation that toggles the
completedstatus of a todo - Implement optimistic updates (UI updates immediately)
- Roll back if the mutation fails
- Make the todo item tappable to trigger the toggle
Show Hint
Use useMutation with onMutate, onError, and onSettled callbacks. In onMutate, cancel outgoing queries, snapshot the current data, and optimistically update. In onError, restore from the snapshot.
Show Solution
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { View, Text, FlatList, Pressable, StyleSheet } from 'react-native';
interface Todo {
id: number;
title: string;
completed: boolean;
}
async function toggleTodo(todo: Todo): Promise<Todo> {
const response = await fetch(
`https://jsonplaceholder.typicode.com/todos/${todo.id}`,
{
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ completed: !todo.completed }),
}
);
if (!response.ok) throw new Error('Failed to update todo');
return response.json();
}
function useToggleTodo() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: toggleTodo,
onMutate: async (todo) => {
// Cancel outgoing refetches
await queryClient.cancelQueries({ queryKey: ['todos'] });
// Snapshot current data
const previousTodos = queryClient.getQueryData<Todo[]>(['todos']);
// Optimistically update
queryClient.setQueryData<Todo[]>(['todos'], (old) =>
old?.map((t) =>
t.id === todo.id ? { ...t, completed: !t.completed } : t
)
);
return { previousTodos };
},
onError: (err, todo, context) => {
// Roll back on error
if (context?.previousTodos) {
queryClient.setQueryData(['todos'], context.previousTodos);
}
},
onSettled: () => {
// Refetch to ensure consistency
queryClient.invalidateQueries({ queryKey: ['todos'] });
},
});
}
function TodoItem({ todo }: { todo: Todo }) {
const toggleMutation = useToggleTodo();
return (
<Pressable
onPress={() => toggleMutation.mutate(todo)}
style={({ pressed }) => [
styles.todoItem,
pressed && styles.pressed,
]}
>
<Text style={[
styles.todoTitle,
todo.completed && styles.completed,
]}>
{todo.completed ? '✓ ' : '○ '}{todo.title}
</Text>
</Pressable>
);
}
// Use TodoItem in FlatList renderItem
// renderItem={({ item }) => <TodoItem todo={item} />}
Exercise 3: Infinite Scroll Posts
Implement infinite scroll for a list of posts using useInfiniteQuery.
Requirements:
- Fetch posts from JSONPlaceholder with pagination (10 per page)
- Implement infinite scroll using FlatList's onEndReached
- Show a loading indicator at the bottom while fetching more
- Display "No more posts" when all posts are loaded
Show Hint
JSONPlaceholder supports _start and _limit query parameters. Use page index * limit for _start. Flatten pages with data.pages.flatMap(). Check hasNextPage before calling fetchNextPage.
Show Solution
import { useInfiniteQuery } from '@tanstack/react-query';
import {
View,
Text,
FlatList,
ActivityIndicator,
StyleSheet
} from 'react-native';
interface Post {
id: number;
title: string;
body: string;
}
const POSTS_PER_PAGE = 10;
const TOTAL_POSTS = 100; // JSONPlaceholder has 100 posts
async function fetchPosts({ pageParam = 0 }): Promise<Post[]> {
const response = await fetch(
`https://jsonplaceholder.typicode.com/posts?_start=${pageParam}&_limit=${POSTS_PER_PAGE}`
);
if (!response.ok) throw new Error('Failed to fetch posts');
return response.json();
}
export default function InfinitePostList() {
const {
data,
fetchNextPage,
hasNextPage,
isFetchingNextPage,
isLoading,
isError,
error,
refetch,
isRefetching,
} = useInfiniteQuery({
queryKey: ['posts', 'infinite'],
queryFn: fetchPosts,
initialPageParam: 0,
getNextPageParam: (lastPage, allPages) => {
const totalFetched = allPages.flat().length;
// JSONPlaceholder has 100 posts total
return totalFetched < TOTAL_POSTS
? totalFetched
: undefined;
},
});
const allPosts = data?.pages.flat() ?? [];
const loadMore = () => {
if (hasNextPage && !isFetchingNextPage) {
fetchNextPage();
}
};
if (isLoading) {
return (
<View style={styles.centered}>
<ActivityIndicator size="large" color="#667eea" />
</View>
);
}
if (isError) {
return (
<View style={styles.centered}>
<Text style={styles.error}>{error.message}</Text>
</View>
);
}
return (
<FlatList
data={allPosts}
keyExtractor={(item) => item.id.toString()}
contentContainerStyle={styles.list}
onEndReached={loadMore}
onEndReachedThreshold={0.5}
onRefresh={refetch}
refreshing={isRefetching}
renderItem={({ item }) => (
<View style={styles.postCard}>
<Text style={styles.title}>{item.title}</Text>
<Text style={styles.body} numberOfLines={2}>
{item.body}
</Text>
</View>
)}
ListFooterComponent={() => (
<View style={styles.footer}>
{isFetchingNextPage ? (
<ActivityIndicator color="#667eea" />
) : hasNextPage ? null : (
<Text style={styles.endText}>No more posts</Text>
)}
</View>
)}
/>
);
}
const styles = StyleSheet.create({
centered: {
flex: 1,
justifyContent: 'center',
alignItems: 'center',
},
list: {
padding: 16,
},
postCard: {
backgroundColor: 'white',
padding: 16,
borderRadius: 8,
marginBottom: 12,
shadowColor: '#000',
shadowOffset: { width: 0, height: 1 },
shadowOpacity: 0.1,
shadowRadius: 4,
elevation: 2,
},
title: {
fontSize: 16,
fontWeight: 'bold',
marginBottom: 8,
},
body: {
fontSize: 14,
color: '#666',
},
footer: {
padding: 20,
alignItems: 'center',
},
endText: {
color: '#999',
},
error: {
color: 'red',
},
});
Summary
Data fetching libraries transform how you manage server state in React Native apps. They handle the complex problems of caching, synchronization, and updates so you can focus on your UI.
🎯 Key Takeaways
- Server state is different from client state—it lives remotely and can become stale
- TanStack Query provides useQuery, useMutation, and useInfiniteQuery for all data operations
- Query keys uniquely identify cached data and control refetching behavior
- Stale-while-revalidate shows cached data instantly while fetching fresh data
- Mutations with cache invalidation keep your UI in sync with the server
- Optimistic updates make your app feel instant by updating before server confirmation
- Infinite queries simplify paginated data with built-in page management
- SWR is a simpler alternative for basic data fetching needs
- Choose TanStack Query for most React Native apps—the DevTools and features are worth it
In the next lesson, we'll explore local storage options for persisting data on the device—essential for offline functionality and improving app startup times.