Back to Blog
React QueryData FetchingReactState Management

React Query Patterns for Data Fetching

Master React Query for server state management. From basic queries to mutations to infinite scrolling and optimistic updates.

B
Bootspring Team
Engineering
December 2, 2021
6 min read

React Query (TanStack Query) simplifies server state management. Here's how to use it effectively.

Basic Setup#

1// lib/queryClient.ts 2import { QueryClient } from '@tanstack/react-query'; 3 4export const queryClient = new QueryClient({ 5 defaultOptions: { 6 queries: { 7 staleTime: 1000 * 60 * 5, // 5 minutes 8 gcTime: 1000 * 60 * 30, // 30 minutes (formerly cacheTime) 9 retry: 3, 10 refetchOnWindowFocus: true, 11 refetchOnReconnect: true, 12 }, 13 mutations: { 14 retry: 1, 15 }, 16 }, 17}); 18 19// App wrapper 20import { QueryClientProvider } from '@tanstack/react-query'; 21import { ReactQueryDevtools } from '@tanstack/react-query-devtools'; 22 23function App({ children }: { children: React.ReactNode }) { 24 return ( 25 <QueryClientProvider client={queryClient}> 26 {children} 27 <ReactQueryDevtools initialIsOpen={false} /> 28 </QueryClientProvider> 29 ); 30}

Query Hooks#

1// hooks/useUsers.ts 2import { useQuery, useSuspenseQuery } from '@tanstack/react-query'; 3 4// Query keys factory 5export const userKeys = { 6 all: ['users'] as const, 7 lists: () => [...userKeys.all, 'list'] as const, 8 list: (filters: UserFilters) => [...userKeys.lists(), filters] as const, 9 details: () => [...userKeys.all, 'detail'] as const, 10 detail: (id: string) => [...userKeys.details(), id] as const, 11}; 12 13// Query functions 14async function fetchUsers(filters: UserFilters): Promise<User[]> { 15 const params = new URLSearchParams(filters as Record<string, string>); 16 const response = await fetch(`/api/users?${params}`); 17 18 if (!response.ok) { 19 throw new Error('Failed to fetch users'); 20 } 21 22 return response.json(); 23} 24 25async function fetchUser(id: string): Promise<User> { 26 const response = await fetch(`/api/users/${id}`); 27 28 if (!response.ok) { 29 throw new Error('Failed to fetch user'); 30 } 31 32 return response.json(); 33} 34 35// Hooks 36export function useUsers(filters: UserFilters = {}) { 37 return useQuery({ 38 queryKey: userKeys.list(filters), 39 queryFn: () => fetchUsers(filters), 40 }); 41} 42 43export function useUser(id: string) { 44 return useQuery({ 45 queryKey: userKeys.detail(id), 46 queryFn: () => fetchUser(id), 47 enabled: !!id, 48 }); 49} 50 51// Suspense version 52export function useUserSuspense(id: string) { 53 return useSuspenseQuery({ 54 queryKey: userKeys.detail(id), 55 queryFn: () => fetchUser(id), 56 }); 57} 58 59// Component usage 60function UserList() { 61 const { data: users, isLoading, error } = useUsers({ status: 'active' }); 62 63 if (isLoading) return <Spinner />; 64 if (error) return <Error message={error.message} />; 65 66 return ( 67 <ul> 68 {users?.map((user) => ( 69 <li key={user.id}>{user.name}</li> 70 ))} 71 </ul> 72 ); 73}

Mutations#

1// hooks/useUserMutations.ts 2import { useMutation, useQueryClient } from '@tanstack/react-query'; 3 4async function createUser(data: CreateUserInput): Promise<User> { 5 const response = await fetch('/api/users', { 6 method: 'POST', 7 headers: { 'Content-Type': 'application/json' }, 8 body: JSON.stringify(data), 9 }); 10 11 if (!response.ok) { 12 throw new Error('Failed to create user'); 13 } 14 15 return response.json(); 16} 17 18export function useCreateUser() { 19 const queryClient = useQueryClient(); 20 21 return useMutation({ 22 mutationFn: createUser, 23 onSuccess: (newUser) => { 24 // Invalidate and refetch 25 queryClient.invalidateQueries({ queryKey: userKeys.lists() }); 26 27 // Or update cache directly 28 queryClient.setQueryData(userKeys.detail(newUser.id), newUser); 29 }, 30 onError: (error) => { 31 console.error('Failed to create user:', error); 32 }, 33 }); 34} 35 36export function useUpdateUser() { 37 const queryClient = useQueryClient(); 38 39 return useMutation({ 40 mutationFn: ({ id, data }: { id: string; data: UpdateUserInput }) => 41 updateUser(id, data), 42 onMutate: async ({ id, data }) => { 43 // Cancel outgoing refetches 44 await queryClient.cancelQueries({ queryKey: userKeys.detail(id) }); 45 46 // Snapshot previous value 47 const previousUser = queryClient.getQueryData(userKeys.detail(id)); 48 49 // Optimistically update 50 queryClient.setQueryData(userKeys.detail(id), (old: User) => ({ 51 ...old, 52 ...data, 53 })); 54 55 return { previousUser }; 56 }, 57 onError: (err, { id }, context) => { 58 // Rollback on error 59 queryClient.setQueryData(userKeys.detail(id), context?.previousUser); 60 }, 61 onSettled: (_, __, { id }) => { 62 // Always refetch after error or success 63 queryClient.invalidateQueries({ queryKey: userKeys.detail(id) }); 64 }, 65 }); 66} 67 68// Usage 69function CreateUserForm() { 70 const { mutate, isPending, error } = useCreateUser(); 71 72 const handleSubmit = (data: CreateUserInput) => { 73 mutate(data, { 74 onSuccess: (user) => { 75 toast.success('User created!'); 76 router.push(`/users/${user.id}`); 77 }, 78 }); 79 }; 80 81 return ( 82 <form onSubmit={handleSubmit}> 83 {/* form fields */} 84 <button type="submit" disabled={isPending}> 85 {isPending ? 'Creating...' : 'Create User'} 86 </button> 87 {error && <p className="error">{error.message}</p>} 88 </form> 89 ); 90}

Infinite Queries#

1// hooks/useInfinitePosts.ts 2import { useInfiniteQuery } from '@tanstack/react-query'; 3 4interface PostsPage { 5 posts: Post[]; 6 nextCursor: string | null; 7} 8 9async function fetchPosts({ pageParam = null }): Promise<PostsPage> { 10 const url = pageParam 11 ? `/api/posts?cursor=${pageParam}` 12 : '/api/posts'; 13 14 const response = await fetch(url); 15 return response.json(); 16} 17 18export function useInfinitePosts() { 19 return useInfiniteQuery({ 20 queryKey: ['posts', 'infinite'], 21 queryFn: fetchPosts, 22 initialPageParam: null, 23 getNextPageParam: (lastPage) => lastPage.nextCursor, 24 getPreviousPageParam: (firstPage) => firstPage.prevCursor, 25 }); 26} 27 28// Component with infinite scroll 29function PostFeed() { 30 const { 31 data, 32 fetchNextPage, 33 hasNextPage, 34 isFetchingNextPage, 35 } = useInfinitePosts(); 36 37 const posts = data?.pages.flatMap((page) => page.posts) ?? []; 38 39 // Intersection Observer for infinite scroll 40 const loadMoreRef = useRef<HTMLDivElement>(null); 41 42 useEffect(() => { 43 const observer = new IntersectionObserver( 44 (entries) => { 45 if (entries[0].isIntersecting && hasNextPage && !isFetchingNextPage) { 46 fetchNextPage(); 47 } 48 }, 49 { threshold: 0.1 } 50 ); 51 52 if (loadMoreRef.current) { 53 observer.observe(loadMoreRef.current); 54 } 55 56 return () => observer.disconnect(); 57 }, [hasNextPage, isFetchingNextPage, fetchNextPage]); 58 59 return ( 60 <div> 61 {posts.map((post) => ( 62 <PostCard key={post.id} post={post} /> 63 ))} 64 65 <div ref={loadMoreRef}> 66 {isFetchingNextPage && <Spinner />} 67 </div> 68 </div> 69 ); 70}

Prefetching#

1// Prefetch on hover 2function UserLink({ userId }: { userId: string }) { 3 const queryClient = useQueryClient(); 4 5 const prefetchUser = () => { 6 queryClient.prefetchQuery({ 7 queryKey: userKeys.detail(userId), 8 queryFn: () => fetchUser(userId), 9 staleTime: 1000 * 60 * 5, // 5 minutes 10 }); 11 }; 12 13 return ( 14 <Link 15 href={`/users/${userId}`} 16 onMouseEnter={prefetchUser} 17 onFocus={prefetchUser} 18 > 19 View User 20 </Link> 21 ); 22} 23 24// Server-side prefetching (Next.js) 25// app/users/[id]/page.tsx 26import { dehydrate, HydrationBoundary } from '@tanstack/react-query'; 27import { getQueryClient } from '@/lib/queryClient'; 28 29export default async function UserPage({ params }: { params: { id: string } }) { 30 const queryClient = getQueryClient(); 31 32 await queryClient.prefetchQuery({ 33 queryKey: userKeys.detail(params.id), 34 queryFn: () => fetchUser(params.id), 35 }); 36 37 return ( 38 <HydrationBoundary state={dehydrate(queryClient)}> 39 <UserProfile userId={params.id} /> 40 </HydrationBoundary> 41 ); 42}

Dependent Queries#

1// Query that depends on another query 2function useUserPosts(userId: string) { 3 const { data: user } = useUser(userId); 4 5 return useQuery({ 6 queryKey: ['users', userId, 'posts'], 7 queryFn: () => fetchUserPosts(userId), 8 enabled: !!user, // Only fetch when user is loaded 9 }); 10} 11 12// Parallel queries 13function useDashboardData() { 14 const results = useQueries({ 15 queries: [ 16 { 17 queryKey: ['dashboard', 'stats'], 18 queryFn: fetchStats, 19 }, 20 { 21 queryKey: ['dashboard', 'recent-orders'], 22 queryFn: fetchRecentOrders, 23 }, 24 { 25 queryKey: ['dashboard', 'notifications'], 26 queryFn: fetchNotifications, 27 }, 28 ], 29 }); 30 31 const isLoading = results.some((r) => r.isLoading); 32 const isError = results.some((r) => r.isError); 33 34 return { 35 stats: results[0].data, 36 orders: results[1].data, 37 notifications: results[2].data, 38 isLoading, 39 isError, 40 }; 41}

Query Filters and Selectors#

1// Select specific data 2function useUserName(userId: string) { 3 return useQuery({ 4 queryKey: userKeys.detail(userId), 5 queryFn: () => fetchUser(userId), 6 select: (user) => user.name, 7 }); 8} 9 10// Transform data 11function useUserFullName(userId: string) { 12 return useQuery({ 13 queryKey: userKeys.detail(userId), 14 queryFn: () => fetchUser(userId), 15 select: (user) => `${user.firstName} ${user.lastName}`, 16 }); 17} 18 19// Memoized selector 20const selectActiveUsers = (users: User[]) => 21 users.filter((u) => u.status === 'active'); 22 23function useActiveUsers() { 24 return useQuery({ 25 queryKey: userKeys.all, 26 queryFn: fetchAllUsers, 27 select: selectActiveUsers, 28 }); 29}

Best Practices#

Structure: ✓ Use query key factories ✓ Colocate queries with features ✓ Abstract fetch functions ✓ Type everything Performance: ✓ Use staleTime appropriately ✓ Prefetch on hover/focus ✓ Use select for derived data ✓ Avoid over-fetching Mutations: ✓ Implement optimistic updates ✓ Handle errors gracefully ✓ Invalidate related queries ✓ Show loading states

Conclusion#

React Query handles server state elegantly. Use query keys factories for organization, implement optimistic updates for responsiveness, and leverage prefetching for perceived performance. The combination of automatic caching, background updates, and devtools makes data fetching straightforward.

Share this article

Help spread the word about Bootspring