tanstack-query-mobile
TanStack Query for React Native data fetching. Use when implementing server state.
When & Why to Use This Skill
This Claude skill provides comprehensive patterns and boilerplate for implementing TanStack Query (formerly React Query) in React Native applications. It streamlines server state management by offering standardized approaches for data fetching, caching, synchronization, and optimistic UI updates, ensuring efficient and performant mobile data handling.
Use Cases
- Implementing efficient API data fetching with automatic caching and background synchronization in React Native apps.
- Managing complex server state and pagination for mobile lists using infinite queries and FlashList integration.
- Enhancing user experience with optimistic updates, allowing the UI to reflect changes immediately before server confirmation.
- Handling authenticated requests and token-based API calls with centralized query logic and interceptors.
- Implementing robust 'Pull to Refresh' functionality and loading states for a seamless mobile interface.
| name | tanstack-query-mobile |
|---|---|
| description | TanStack Query for React Native data fetching. Use when implementing server state. |
TanStack Query Mobile Skill
This skill covers TanStack Query for React Native apps.
When to Use
Use this skill when:
- Fetching data from APIs
- Caching server data
- Synchronizing server state
- Implementing pagination
- Optimistic updates
Core Principle
SERVER STATE - TanStack Query manages server state. Use Zustand for client state.
Installation
npm install @tanstack/react-query
Setup
// app/_layout.tsx
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: 1000 * 60 * 5, // 5 minutes
gcTime: 1000 * 60 * 30, // 30 minutes (formerly cacheTime)
retry: 2,
refetchOnWindowFocus: false, // Disable for mobile
},
},
});
export default function RootLayout(): React.ReactElement {
return (
<QueryClientProvider client={queryClient}>
<Stack />
</QueryClientProvider>
);
}
Basic Query
import { useQuery } from '@tanstack/react-query';
interface User {
id: string;
name: string;
email: string;
}
async function fetchUser(id: string): Promise<User> {
const response = await fetch(`/api/users/${id}`);
if (!response.ok) throw new Error('Failed to fetch user');
return response.json();
}
function UserProfile({ userId }: { userId: string }): React.ReactElement {
const { data: user, isLoading, isError, error } = useQuery({
queryKey: ['user', userId],
queryFn: () => fetchUser(userId),
});
if (isLoading) {
return <ActivityIndicator />;
}
if (isError) {
return <Text>Error: {error.message}</Text>;
}
return (
<View>
<Text>{user.name}</Text>
<Text>{user.email}</Text>
</View>
);
}
Query with Authentication
import { useQuery } from '@tanstack/react-query';
import { useAuthStore } from '@/store/authStore';
function useUserPosts() {
const token = useAuthStore((state) => state.token);
return useQuery({
queryKey: ['posts', 'user'],
queryFn: async () => {
const response = await fetch('/api/posts', {
headers: {
Authorization: `Bearer ${token}`,
},
});
if (!response.ok) throw new Error('Failed to fetch posts');
return response.json();
},
enabled: !!token, // Only fetch when authenticated
});
}
Mutations
import { useMutation, useQueryClient } from '@tanstack/react-query';
interface CreatePostInput {
title: string;
content: string;
}
function useCreatePost() {
const queryClient = useQueryClient();
const token = useAuthStore((state) => state.token);
return useMutation({
mutationFn: async (input: CreatePostInput) => {
const response = await fetch('/api/posts', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${token}`,
},
body: JSON.stringify(input),
});
if (!response.ok) throw new Error('Failed to create post');
return response.json();
},
onSuccess: () => {
// Invalidate posts query to refetch
queryClient.invalidateQueries({ queryKey: ['posts'] });
},
});
}
// Usage
function CreatePostForm(): React.ReactElement {
const { mutate, isPending } = useCreatePost();
const handleSubmit = (data: CreatePostInput) => {
mutate(data, {
onSuccess: () => {
// Navigate or show success
},
onError: (error) => {
// Show error toast
},
});
};
return (
<Button onPress={() => handleSubmit({ title, content })} disabled={isPending}>
{isPending ? 'Creating...' : 'Create Post'}
</Button>
);
}
Optimistic Updates
function useLikePost() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (postId: string) =>
fetch(`/api/posts/${postId}/like`, { method: 'POST' }),
onMutate: async (postId) => {
// Cancel outgoing refetches
await queryClient.cancelQueries({ queryKey: ['posts'] });
// Snapshot previous value
const previousPosts = queryClient.getQueryData(['posts']);
// Optimistically update
queryClient.setQueryData(['posts'], (old: Post[]) =>
old.map((post) =>
post.id === postId
? { ...post, likes: post.likes + 1, isLiked: true }
: post
)
);
return { previousPosts };
},
onError: (err, postId, context) => {
// Rollback on error
queryClient.setQueryData(['posts'], context?.previousPosts);
},
onSettled: () => {
// Refetch after error or success
queryClient.invalidateQueries({ queryKey: ['posts'] });
},
});
}
Infinite Queries (Pagination)
import { useInfiniteQuery } from '@tanstack/react-query';
import { FlashList } from '@shopify/flash-list';
interface PostsResponse {
posts: Post[];
nextCursor: string | null;
}
function useInfinitePosts() {
return useInfiniteQuery({
queryKey: ['posts', 'infinite'],
queryFn: async ({ pageParam }) => {
const response = await fetch(
`/api/posts?cursor=${pageParam ?? ''}&limit=20`
);
return response.json() as Promise<PostsResponse>;
},
initialPageParam: null as string | null,
getNextPageParam: (lastPage) => lastPage.nextCursor,
});
}
function PostsList(): React.ReactElement {
const {
data,
fetchNextPage,
hasNextPage,
isFetchingNextPage,
} = useInfinitePosts();
const posts = data?.pages.flatMap((page) => page.posts) ?? [];
return (
<FlashList
data={posts}
renderItem={({ item }) => <PostCard post={item} />}
estimatedItemSize={100}
onEndReached={() => {
if (hasNextPage && !isFetchingNextPage) {
fetchNextPage();
}
}}
onEndReachedThreshold={0.5}
ListFooterComponent={
isFetchingNextPage ? <ActivityIndicator /> : null
}
/>
);
}
Pull to Refresh
import { RefreshControl } from 'react-native';
function PostsList(): React.ReactElement {
const { data, isLoading, refetch, isRefetching } = usePosts();
return (
<FlashList
data={data}
renderItem={renderItem}
estimatedItemSize={100}
refreshControl={
<RefreshControl
refreshing={isRefetching}
onRefresh={refetch}
/>
}
/>
);
}
Query Hooks Pattern
// hooks/queries/usePosts.ts
export function usePosts() {
return useQuery({
queryKey: ['posts'],
queryFn: fetchPosts,
});
}
export function usePost(id: string) {
return useQuery({
queryKey: ['posts', id],
queryFn: () => fetchPost(id),
enabled: !!id,
});
}
// hooks/mutations/usePostMutations.ts
export function useCreatePost() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: createPost,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['posts'] });
},
});
}
export function useDeletePost() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: deletePost,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['posts'] });
},
});
}
API Client
// lib/api.ts
import axios from 'axios';
import * as SecureStore from 'expo-secure-store';
const api = axios.create({
baseURL: process.env.EXPO_PUBLIC_API_URL,
});
api.interceptors.request.use(async (config) => {
const token = await SecureStore.getItemAsync('authToken');
if (token) {
config.headers.Authorization = `Bearer ${token}`;
}
return config;
});
export default api;
Notes
- Use queryKey for cache identification
- Invalidate queries after mutations
- Use enabled for conditional fetching
- Implement optimistic updates for better UX
- Use infinite queries for pagination
- Create custom hooks for reusability