Courses/Grit Mobile/API Integration & Offline
Course 3 of 5~30 min10 challenges

API Integration & Offline

Mobile apps need to handle slow networks, offline states, and large lists gracefully. In this course, you will learn to use TanStack Query for data fetching, implement pull-to-refresh and infinite scroll, and add offline support with persistent caching.


TanStack Query in React Native

If you've used TanStack Query (React Query) on the web, good news — it works identically in React Native. The sameuseQuery, useMutation, and useInfiniteQuery hooks are available with the exact same API.

React Query (TanStack Query): A data-fetching library that handles loading states, caching, background refetching, and error handling automatically. Instead of writing useState + useEffect + loading/error/data state manually, you call useQuery and get everything for free.

The only difference on mobile is that you get additional patterns like pull-to-refresh and infinite scroll that are specific to mobile UX conventions.

1

Challenge: Find the Query Provider

Open the root layout of the Expo app. Find where QueryClientProvider is set up. What configuration options are passed to the QueryClient?

Fetching Data

Use useQuery to fetch a list of resources from the API:

import { useQuery } from '@tanstack/react-query'
import { api } from '@/lib/api'

export default function TasksScreen() {
  const { data, isLoading, error } = useQuery({
    queryKey: ['tasks'],
    queryFn: () => api.get('/api/tasks'),
  })

  if (isLoading) return <ActivityIndicator />
  if (error) return <Text>Error loading tasks</Text>

  return (
    <FlatList
      data={data.data}
      keyExtractor={(item) => item.id.toString()}
      renderItem={({ item }) => (
        <View style={{ padding: 16 }}>
          <Text style={{ color: '#e8e8f0' }}>{item.title}</Text>
        </View>
      )}
    />
  )
}
Always use FlatList instead of ScrollView for lists of data.FlatList only renders items that are visible on screen, which is critical for performance with large datasets.
2

Challenge: Find a useQuery Hook

Find a useQuery hook in the scaffolded mobile app. What queryKey does it use? What endpoint does the queryFn call?

Creating Data

Use useMutation to create, update, or delete data. The key pattern is cache invalidation — after a successful mutation, you tell React Query to refetch the relevant queries so the list updates automatically.

import { useMutation, useQueryClient } from '@tanstack/react-query'

export function useCreateTask() {
  const queryClient = useQueryClient()

  return useMutation({
    mutationFn: (data: { title: string; description: string }) =>
      api.post('/api/tasks', data),
    onSuccess: () => {
      // Refetch the tasks list after creating a new one
      queryClient.invalidateQueries({ queryKey: ['tasks'] })
    },
  })
}

// In a component:
const createTask = useCreateTask()
createTask.mutate({ title: 'New Task', description: 'Do this' })
Cache Invalidation: Marking cached data as stale so it gets refetched. When you create a new task, the tasks list cache is invalidated, causing useQuery to automatically refetch the list. The UI updates without manual state management.
3

Challenge: Find a useMutation Hook

Find a useMutation hook in the scaffolded code. What happens in the onSuccess callback? Which query keys are invalidated?

Pull-to-Refresh

Pull-to-Refresh: A mobile UX pattern where the user pulls down on a list to trigger a data refresh. A loading spinner appears at the top of the list, the data is refetched, and the spinner disappears. This is a standard interaction on both iOS and Android that users expect in any app that displays data.

Combine React Native's RefreshControl with the query's refetch function:

import { RefreshControl, FlatList } from 'react-native'

export default function TasksScreen() {
  const { data, isLoading, isRefetching, refetch } = useQuery({
    queryKey: ['tasks'],
    queryFn: () => api.get('/api/tasks'),
  })

  return (
    <FlatList
      data={data?.data}
      keyExtractor={(item) => item.id.toString()}
      renderItem={({ item }) => <TaskItem task={item} />}
      refreshControl={
        <RefreshControl
          refreshing={isRefetching}
          onRefresh={refetch}
          tintColor="#6c5ce7"
        />
      }
    />
  )
}
The tintColor prop sets the color of the loading spinner. Use your app's accent color so it matches your theme.
4

Challenge: Add Pull-to-Refresh

Add a RefreshControl to a list screen in the app. Pull down — does the loading spinner appear? Does the data refresh when you release?

Infinite Scrolling

Infinite Scroll: Loading more data automatically as the user scrolls to the bottom of a list. Instead of pagination buttons (which feel awkward on mobile), new items appear seamlessly as the user scrolls. Social media feeds, news apps, and messaging apps all use this pattern.

Use useInfiniteQuery with FlatList's onEndReached callback:

import { useInfiniteQuery } from '@tanstack/react-query'

export default function TasksScreen() {
  const {
    data,
    fetchNextPage,
    hasNextPage,
    isFetchingNextPage,
  } = useInfiniteQuery({
    queryKey: ['tasks'],
    queryFn: ({ pageParam = 1 }) =>
      api.get('/api/tasks?page=' + pageParam + '&page_size=20'),
    initialPageParam: 1,
    getNextPageParam: (lastPage) =>
      lastPage.meta.page < lastPage.meta.pages
        ? lastPage.meta.page + 1
        : undefined,
  })

  // Flatten all pages into a single array
  const tasks = data?.pages.flatMap((page) => page.data) ?? []

  return (
    <FlatList
      data={tasks}
      keyExtractor={(item) => item.id.toString()}
      renderItem={({ item }) => <TaskItem task={item} />}
      onEndReached={() => {
        if (hasNextPage) fetchNextPage()
      }}
      onEndReachedThreshold={0.5}
      ListFooterComponent={
        isFetchingNextPage ? <ActivityIndicator /> : null
      }
    />
  )
}

The getNextPageParam function tells React Query how to get the next page. It reads the meta object from the API response (which Grit's paginated endpoints always include) and returns the next page number. When there are no more pages, it returns undefined and hasNextPage becomes false.

5

Challenge: Test Infinite Scroll

If you have enough records (create 20+ via the API), test infinite scroll by scrolling to the bottom of a list. Does the loading spinner appear? Do new items load automatically?

Offline Support

Offline-First: An app design approach where the app works without an internet connection by using locally cached data. When connectivity returns, the app syncs with the server. This provides a smooth experience even on unreliable mobile networks.

TanStack Query caches all fetched data in memory by default. This means if the user navigates away from a screen and comes back, the data is shown instantly from cache (and refetched in the background). For persistent offline support — data that survives app restarts — you use AsyncStorage as a query persister.

import AsyncStorage from '@react-native-async-storage/async-storage'
import { createAsyncStoragePersister } from '@tanstack/query-async-storage-persister'
import { PersistQueryClientProvider } from '@tanstack/react-query-persist-client'

const persister = createAsyncStoragePersister({
  storage: AsyncStorage,
})

// Wrap your app with PersistQueryClientProvider
export default function App() {
  return (
    <PersistQueryClientProvider
      client={queryClient}
      persistOptions={{ persister }}
    >
      <YourApp />
    </PersistQueryClientProvider>
  )
}

With this setup, when the user opens your app with no internet connection, they see the last fetched data from AsyncStorage. When connectivity returns, React Query automatically refetches in the background and updates the UI.

6

Challenge: Test In-Memory Cache

Load some data on a list screen, then navigate to a different tab and come back. Does the data appear instantly (from cache) or does it show a loading state?

7

Challenge: Test Offline Mode

Load some data, then turn on airplane mode on your device. Can you still see the cached data? Navigate between screens — does the cached data persist?

Error Handling

Mobile apps face unreliable networks more often than web apps. Your error UI should be clear and actionable — always give the user a way to retry:

export default function TasksScreen() {
  const { data, isLoading, error, refetch } = useQuery({
    queryKey: ['tasks'],
    queryFn: () => api.get('/api/tasks'),
    retry: 2, // Retry failed requests twice
  })

  if (error) {
    return (
      <View style={{ flex: 1, justifyContent: 'center', alignItems: 'center' }}>
        <Text style={{ color: '#ff6b6b', fontSize: 16, marginBottom: 12 }}>
          Could not load tasks
        </Text>
        <Text style={{ color: '#9090a8', marginBottom: 16 }}>
          Check your internet connection and try again
        </Text>
        <TouchableOpacity
          onPress={() => refetch()}
          style={{ backgroundColor: '#6c5ce7', paddingHorizontal: 24, paddingVertical: 12, borderRadius: 8 }}
        >
          <Text style={{ color: '#fff', fontWeight: '600' }}>Retry</Text>
        </TouchableOpacity>
      </View>
    )
  }

  // ... render data
}
Set retry: 2 on your queries so React Query automatically retries failed requests before showing an error. This handles transient network issues without bothering the user.
8

Challenge: Test Error Handling

Stop the API server (Ctrl+C in the API terminal). What does the mobile app show? Is there a retry button? Start the API again and tap retry — does the data load?

Summary

Here's what you learned in this course:

  • TanStack Query works identically in React Native — same hooks, same API
  • useQuery for fetching, useMutation for creating/updating with cache invalidation
  • Pull-to-refresh uses RefreshControl with refetch
  • Infinite scroll uses useInfiniteQuery with onEndReached
  • Offline support with AsyncStorage persister keeps data across app restarts
9

Challenge: Build a Resource Screen

Generate a Workout resource on the API with grit generate resource Workout title:string duration:int calories:int. Build a mobile screen with a list (useQuery), create form (useMutation), and pull-to-refresh.

10

Challenge: Add Infinite Scroll

Create at least 25 workout records through the API or the create form. Implement infinite scroll on the workout list. Scroll to the bottom — do new items load? Add a footer loading indicator.