Skip to main content

React Native

TanStack Query works seamlessly with React Native, providing the same powerful data synchronization features for mobile apps.

Installation

Install React Query in your React Native project:
npm install @tanstack/react-query
React Query supports React Native 0.70+ and works with Expo, bare React Native, and React Native Web.

Setup

Configure React Query in your React Native app:
App.tsx
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { StatusBar } from 'expo-status-bar'
import { View } from 'react-native'

const queryClient = new QueryClient({
  defaultOptions: {
    queries: {
      retry: 2,
      staleTime: 60 * 1000, // 1 minute
    },
  },
})

export default function App() {
  return (
    <QueryClientProvider client={queryClient}>
      <View style={{ flex: 1 }}>
        <StatusBar style="auto" />
        <YourApp />
      </View>
    </QueryClientProvider>
  )
}

Basic Usage

Use React Query hooks just like in React web apps:
screens/PostsScreen.tsx
import { useQuery } from '@tanstack/react-query'
import {
  View,
  Text,
  FlatList,
  ActivityIndicator,
  StyleSheet,
} from 'react-native'

interface Post {
  id: number
  title: string
  body: string
}

async function fetchPosts(): Promise<Post[]> {
  const response = await fetch('https://jsonplaceholder.typicode.com/posts')
  if (!response.ok) {
    throw new Error('Network response was not ok')
  }
  return response.json()
}

export function PostsScreen() {
  const { data, isLoading, error, refetch } = useQuery({
    queryKey: ['posts'],
    queryFn: fetchPosts,
  })

  if (isLoading) {
    return (
      <View style={styles.centered}>
        <ActivityIndicator size="large" />
      </View>
    )
  }

  if (error) {
    return (
      <View style={styles.centered}>
        <Text>Error: {error.message}</Text>
      </View>
    )
  }

  return (
    <FlatList
      data={data}
      keyExtractor={(item) => item.id.toString()}
      renderItem={({ item }) => (
        <View style={styles.post}>
          <Text style={styles.title}>{item.title}</Text>
          <Text>{item.body}</Text>
        </View>
      )}
      onRefresh={refetch}
      refreshing={isLoading}
    />
  )
}

const styles = StyleSheet.create({
  centered: {
    flex: 1,
    justifyContent: 'center',
    alignItems: 'center',
  },
  post: {
    padding: 16,
    borderBottomWidth: 1,
    borderBottomColor: '#eee',
  },
  title: {
    fontSize: 16,
    fontWeight: 'bold',
    marginBottom: 8,
  },
})

Pull-to-Refresh

Integrate with React Native’s pull-to-refresh:
import { useQuery } from '@tanstack/react-query'
import { FlatList, RefreshControl } from 'react-native'

function PostsList() {
  const { data, isLoading, isFetching, refetch } = useQuery({
    queryKey: ['posts'],
    queryFn: fetchPosts,
  })

  return (
    <FlatList
      data={data}
      keyExtractor={(item) => item.id.toString()}
      renderItem={({ item }) => <PostItem item={item} />}
      refreshControl={
        <RefreshControl
          refreshing={isFetching}
          onRefresh={refetch}
        />
      }
    />
  )
}
Use isFetching instead of isLoading for the refresh indicator to show activity during background refetches.

Infinite Scroll

Implement infinite scrolling with useInfiniteQuery:
import { useInfiniteQuery } from '@tanstack/react-query'
import { FlatList, ActivityIndicator, View } from 'react-native'

interface PostsPage {
  posts: Post[]
  nextPage: number | null
}

async function fetchPosts({ pageParam = 1 }): Promise<PostsPage> {
  const response = await fetch(
    `https://api.example.com/posts?page=${pageParam}&limit=20`
  )
  const posts = await response.json()
  return {
    posts,
    nextPage: posts.length === 20 ? pageParam + 1 : null,
  }
}

export function InfinitePostsList() {
  const {
    data,
    fetchNextPage,
    hasNextPage,
    isFetchingNextPage,
    isLoading,
  } = useInfiniteQuery({
    queryKey: ['posts'],
    queryFn: fetchPosts,
    initialPageParam: 1,
    getNextPageParam: (lastPage) => lastPage.nextPage,
  })

  if (isLoading) {
    return <ActivityIndicator />
  }

  const posts = data?.pages.flatMap((page) => page.posts) ?? []

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

Mutations

Handle create, update, and delete operations:
import { useMutation, useQueryClient } from '@tanstack/react-query'
import { useState } from 'react'
import {
  View,
  TextInput,
  TouchableOpacity,
  Text,
  StyleSheet,
  Alert,
} from 'react-native'

interface CreatePostInput {
  title: string
  body: string
}

async function createPost(input: CreatePostInput): Promise<Post> {
  const response = await fetch('https://api.example.com/posts', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify(input),
  })
  return response.json()
}

export function CreatePostForm() {
  const [title, setTitle] = useState('')
  const [body, setBody] = useState('')
  const queryClient = useQueryClient()

  const mutation = useMutation({
    mutationFn: createPost,
    onSuccess: () => {
      queryClient.invalidateQueries({ queryKey: ['posts'] })
      setTitle('')
      setBody('')
      Alert.alert('Success', 'Post created successfully!')
    },
    onError: (error: Error) => {
      Alert.alert('Error', error.message)
    },
  })

  return (
    <View style={styles.form}>
      <TextInput
        style={styles.input}
        placeholder="Title"
        value={title}
        onChangeText={setTitle}
      />
      <TextInput
        style={[styles.input, styles.textArea]}
        placeholder="Body"
        value={body}
        onChangeText={setBody}
        multiline
        numberOfLines={4}
      />
      <TouchableOpacity
        style={[
          styles.button,
          mutation.isPending && styles.buttonDisabled,
        ]}
        onPress={() => mutation.mutate({ title, body })}
        disabled={mutation.isPending}
      >
        <Text style={styles.buttonText}>
          {mutation.isPending ? 'Creating...' : 'Create Post'}
        </Text>
      </TouchableOpacity>
    </View>
  )
}

const styles = StyleSheet.create({
  form: {
    padding: 16,
  },
  input: {
    borderWidth: 1,
    borderColor: '#ddd',
    borderRadius: 8,
    padding: 12,
    marginBottom: 12,
    fontSize: 16,
  },
  textArea: {
    height: 100,
    textAlignVertical: 'top',
  },
  button: {
    backgroundColor: '#007AFF',
    padding: 16,
    borderRadius: 8,
    alignItems: 'center',
  },
  buttonDisabled: {
    opacity: 0.6,
  },
  buttonText: {
    color: 'white',
    fontSize: 16,
    fontWeight: '600',
  },
})

App State Management

React Query automatically refetches when the app comes to foreground:
import { QueryClient } from '@tanstack/react-query'
import { AppState } from 'react-native'
import { focusManager } from '@tanstack/react-query'
import { useEffect } from 'react'

// Configure focus manager for React Native
function onAppStateChange(status: string) {
  focusManager.setFocused(status === 'active')
}

export function useAppState() {
  useEffect(() => {
    const subscription = AppState.addEventListener('change', onAppStateChange)
    return () => subscription.remove()
  }, [])
}

// Use in your app
function App() {
  useAppState()
  
  return (
    <QueryClientProvider client={queryClient}>
      <YourApp />
    </QueryClientProvider>
  )
}
React Query’s focusManager integrates with React Native’s AppState to automatically refetch when users return to your app.

Network State Management

Handle online/offline states:
import { onlineManager } from '@tanstack/react-query'
import NetInfo from '@react-native-community/netinfo'
import { useEffect } from 'react'

export function useNetworkState() {
  useEffect(() => {
    return NetInfo.addEventListener((state) => {
      onlineManager.setOnline(
        state.isConnected != null &&
        state.isConnected &&
        Boolean(state.isInternetReachable)
      )
    })
  }, [])
}

// Use in your app
function App() {
  useNetworkState()
  
  return (
    <QueryClientProvider client={queryClient}>
      <YourApp />
    </QueryClientProvider>
  )
}
Install the required dependency:
npm install @react-native-community/netinfo

Persistence

Persist query cache to AsyncStorage:
npm install @tanstack/react-query-persist-client
npm install @react-native-async-storage/async-storage
import { QueryClient } from '@tanstack/react-query'
import { PersistQueryClientProvider } from '@tanstack/react-query-persist-client'
import { createAsyncStoragePersister } from '@tanstack/query-async-storage-persister'
import AsyncStorage from '@react-native-async-storage/async-storage'

const queryClient = new QueryClient({
  defaultOptions: {
    queries: {
      gcTime: 1000 * 60 * 60 * 24, // 24 hours
    },
  },
})

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

function App() {
  return (
    <PersistQueryClientProvider
      client={queryClient}
      persistOptions={{ persister }}
    >
      <YourApp />
    </PersistQueryClientProvider>
  )
}
Be mindful of AsyncStorage size limits. Consider implementing cache size limits or selective persistence for production apps.

Optimistic Updates

Provide instant feedback in mobile apps:
import { useMutation, useQueryClient } from '@tanstack/react-query'

function useLikeMutation() {
  const queryClient = useQueryClient()

  return useMutation({
    mutationFn: (postId: number) => likePost(postId),
    onMutate: async (postId) => {
      // Cancel outgoing refetches
      await queryClient.cancelQueries({ queryKey: ['post', postId] })

      // Snapshot previous value
      const previousPost = queryClient.getQueryData(['post', postId])

      // Optimistically update
      queryClient.setQueryData(['post', postId], (old: any) => ({
        ...old,
        likes: old.likes + 1,
        isLiked: true,
      }))

      return { previousPost }
    },
    onError: (err, postId, context) => {
      // Rollback on error
      queryClient.setQueryData(['post', postId], context?.previousPost)
    },
    onSettled: (data, error, postId) => {
      queryClient.invalidateQueries({ queryKey: ['post', postId] })
    },
  })
}

React Navigation Integration

Prefetch data when navigating:
import { useQueryClient } from '@tanstack/react-query'
import { useFocusEffect } from '@react-navigation/native'
import { useCallback } from 'react'

function PostDetailsScreen({ route }: { route: any }) {
  const { postId } = route.params
  const queryClient = useQueryClient()

  // Refetch on screen focus
  useFocusEffect(
    useCallback(() => {
      queryClient.invalidateQueries({ queryKey: ['post', postId] })
    }, [queryClient, postId])
  )

  const { data } = useQuery({
    queryKey: ['post', postId],
    queryFn: () => fetchPost(postId),
  })

  return <PostDetails post={data} />
}

// Prefetch in list screen
function PostsListScreen({ navigation }: any) {
  const queryClient = useQueryClient()

  const handlePostPress = (postId: number) => {
    // Prefetch before navigation
    queryClient.prefetchQuery({
      queryKey: ['post', postId],
      queryFn: () => fetchPost(postId),
    })
    
    navigation.navigate('PostDetails', { postId })
  }

  return <PostsList onPostPress={handlePostPress} />
}

Error Boundaries

Handle errors gracefully in React Native:
import { Component, ReactNode } from 'react'
import { View, Text, TouchableOpacity, StyleSheet } from 'react-native'

interface Props {
  children: ReactNode
}

interface State {
  hasError: boolean
  error: Error | null
}

class ErrorBoundary extends Component<Props, State> {
  constructor(props: Props) {
    super(props)
    this.state = { hasError: false, error: null }
  }

  static getDerivedStateFromError(error: Error): State {
    return { hasError: true, error }
  }

  render() {
    if (this.state.hasError) {
      return (
        <View style={styles.container}>
          <Text style={styles.title}>Something went wrong</Text>
          <Text style={styles.message}>
            {this.state.error?.message}
          </Text>
          <TouchableOpacity
            style={styles.button}
            onPress={() => this.setState({ hasError: false, error: null })}
          >
            <Text style={styles.buttonText}>Try Again</Text>
          </TouchableOpacity>
        </View>
      )
    }

    return this.props.children
  }
}

const styles = StyleSheet.create({
  container: {
    flex: 1,
    justifyContent: 'center',
    alignItems: 'center',
    padding: 20,
  },
  title: {
    fontSize: 20,
    fontWeight: 'bold',
    marginBottom: 10,
  },
  message: {
    fontSize: 14,
    color: '#666',
    marginBottom: 20,
    textAlign: 'center',
  },
  button: {
    backgroundColor: '#007AFF',
    paddingHorizontal: 20,
    paddingVertical: 10,
    borderRadius: 8,
  },
  buttonText: {
    color: 'white',
    fontSize: 16,
  },
})

export default ErrorBoundary

Performance Tips

1

Optimize re-renders

Use notifyOnChangeProps to limit updates:
useQuery({
  queryKey: ['posts'],
  queryFn: fetchPosts,
  notifyOnChangeProps: ['data', 'error'],
})
2

Implement pagination

Load data in chunks for better performance:
useInfiniteQuery({
  queryKey: ['posts'],
  queryFn: ({ pageParam = 1 }) => fetchPosts(pageParam),
  initialPageParam: 1,
  getNextPageParam: (lastPage) => lastPage.nextCursor,
})
3

Configure appropriate cache times

const queryClient = new QueryClient({
  defaultOptions: {
    queries: {
      staleTime: 60 * 1000, // 1 minute
      gcTime: 5 * 60 * 1000, // 5 minutes
    },
  },
})
Monitor query performance using React DevTools Profiler and React Query DevTools (web debugging).

Next Steps

TypeScript

Add type safety to your React Native queries

GraphQL

Use GraphQL in React Native apps

Build docs developers (and LLMs) love