Skip to main content

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.

What Data Fetching Libraries Provide Your App Automatic Caching Background Refetch Deduplication Optimistic UI Cache Invalidation Retry Logic Infinite Scroll Stale While Revalidate All handled automatically — you just describe what data you need

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

  1. Immediately show cached data (even if stale)
  2. Background fetch fresh data
  3. Update the UI when fresh data arrives

This means users see something instantly while getting updated data moments later.

Stale-While-Revalidate Pattern Navigate to screen Show cached (instant!) Background fetch starts Fresh data arrives ✅ User sees data the entire time — no loading spinner! ❌ Traditional: Loading... Data appears after fetch completes With SWR: Without:

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.

Optimistic Updates: Instant UI Feedback 1 User taps "Like" 2 UI updates immediately ❤️ 3 Request sent to server Success: Keep change Error: Rollback User perceives: Instant!
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 onEndReachedThreshold to 0.3-0.5 for smoother loading
  • Use inverted prop for chat-style lists (newest at bottom)
  • Consider maintainVisibleContentPosition for 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 completed status 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.