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 withuseInfiniteQuery:
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>
)
}
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
Optimize re-renders
Use
notifyOnChangeProps to limit updates:useQuery({
queryKey: ['posts'],
queryFn: fetchPosts,
notifyOnChangeProps: ['data', 'error'],
})
Implement pagination
Load data in chunks for better performance:
useInfiniteQuery({
queryKey: ['posts'],
queryFn: ({ pageParam = 1 }) => fetchPosts(pageParam),
initialPageParam: 1,
getNextPageParam: (lastPage) => lastPage.nextCursor,
})
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