React Query Patterns

Server state management with TanStack Query.

Overview#

React Query handles caching, synchronization, and server state. This pattern covers:

  • Query client setup
  • Basic queries
  • Mutations with cache updates
  • Optimistic updates
  • Infinite queries
  • Prefetching

Prerequisites#

npm install @tanstack/react-query @tanstack/react-query-devtools

Setup#

Configure the query client and provider.

1// app/providers.tsx 2'use client' 3 4import { QueryClient, QueryClientProvider } from '@tanstack/react-query' 5import { ReactQueryDevtools } from '@tanstack/react-query-devtools' 6import { useState } from 'react' 7 8export function Providers({ children }: { children: React.ReactNode }) { 9 const [queryClient] = useState(() => new QueryClient({ 10 defaultOptions: { 11 queries: { 12 staleTime: 60 * 1000, // 1 minute 13 refetchOnWindowFocus: false 14 } 15 } 16 })) 17 18 return ( 19 <QueryClientProvider client={queryClient}> 20 {children} 21 <ReactQueryDevtools initialIsOpen={false} /> 22 </QueryClientProvider> 23 ) 24} 25 26// app/layout.tsx 27import { Providers } from './providers' 28 29export default function RootLayout({ children }: { children: React.ReactNode }) { 30 return ( 31 <html> 32 <body> 33 <Providers>{children}</Providers> 34 </body> 35 </html> 36 ) 37}

Basic Query#

Fetch and cache data with useQuery.

1// hooks/useUsers.ts 2import { useQuery } from '@tanstack/react-query' 3 4interface User { 5 id: string 6 name: string 7 email: string 8} 9 10async function fetchUsers(): Promise<User[]> { 11 const response = await fetch('/api/users') 12 if (!response.ok) throw new Error('Failed to fetch users') 13 return response.json() 14} 15 16export function useUsers() { 17 return useQuery({ 18 queryKey: ['users'], 19 queryFn: fetchUsers 20 }) 21} 22 23// Usage 24function UserList() { 25 const { data: users, isLoading, error } = useUsers() 26 27 if (isLoading) return <Skeleton /> 28 if (error) return <Error message={error.message} /> 29 30 return ( 31 <ul> 32 {users?.map(user => ( 33 <li key={user.id}>{user.name}</li> 34 ))} 35 </ul> 36 ) 37}

Parameterized Query#

Query with dynamic parameters.

1// hooks/useUser.ts 2import { useQuery } from '@tanstack/react-query' 3 4async function fetchUser(id: string): Promise<User> { 5 const response = await fetch(`/api/users/${id}`) 6 if (!response.ok) throw new Error('User not found') 7 return response.json() 8} 9 10export function useUser(id: string | undefined) { 11 return useQuery({ 12 queryKey: ['users', id], 13 queryFn: () => fetchUser(id!), 14 enabled: !!id // Only fetch when id exists 15 }) 16} 17 18// With options 19export function useUserWithOptions(id: string, options?: { enabled?: boolean }) { 20 return useQuery({ 21 queryKey: ['users', id], 22 queryFn: () => fetchUser(id), 23 enabled: options?.enabled !== false && !!id, 24 staleTime: 5 * 60 * 1000, // 5 minutes 25 gcTime: 10 * 60 * 1000 // 10 minutes (formerly cacheTime) 26 }) 27}

Mutations#

Create, update, and delete data.

1// hooks/useCreateUser.ts 2import { useMutation, useQueryClient } from '@tanstack/react-query' 3 4interface CreateUserData { 5 name: string 6 email: string 7} 8 9async function createUser(data: CreateUserData): Promise<User> { 10 const response = await fetch('/api/users', { 11 method: 'POST', 12 headers: { 'Content-Type': 'application/json' }, 13 body: JSON.stringify(data) 14 }) 15 if (!response.ok) throw new Error('Failed to create user') 16 return response.json() 17} 18 19export function useCreateUser() { 20 const queryClient = useQueryClient() 21 22 return useMutation({ 23 mutationFn: createUser, 24 onSuccess: (newUser) => { 25 // Invalidate and refetch users list 26 queryClient.invalidateQueries({ queryKey: ['users'] }) 27 28 // Or optimistically add to cache 29 queryClient.setQueryData(['users'], (old: User[] | undefined) => 30 old ? [...old, newUser] : [newUser] 31 ) 32 }, 33 onError: (error) => { 34 console.error('Failed to create user:', error) 35 } 36 }) 37} 38 39// Usage 40function CreateUserForm() { 41 const createUser = useCreateUser() 42 43 const handleSubmit = (data: CreateUserData) => { 44 createUser.mutate(data, { 45 onSuccess: () => { 46 toast.success('User created!') 47 } 48 }) 49 } 50 51 return ( 52 <form onSubmit={handleSubmit}> 53 {/* ... */} 54 <button disabled={createUser.isPending}> 55 {createUser.isPending ? 'Creating...' : 'Create'} 56 </button> 57 </form> 58 ) 59}

Optimistic Updates#

Update UI immediately before server confirmation.

1// hooks/useUpdateUser.ts 2import { useMutation, useQueryClient } from '@tanstack/react-query' 3 4export function useUpdateUser() { 5 const queryClient = useQueryClient() 6 7 return useMutation({ 8 mutationFn: updateUser, 9 onMutate: async (updatedUser) => { 10 // Cancel outgoing refetches 11 await queryClient.cancelQueries({ queryKey: ['users', updatedUser.id] }) 12 13 // Snapshot previous value 14 const previousUser = queryClient.getQueryData(['users', updatedUser.id]) 15 16 // Optimistically update 17 queryClient.setQueryData(['users', updatedUser.id], updatedUser) 18 19 // Return context with snapshot 20 return { previousUser } 21 }, 22 onError: (err, updatedUser, context) => { 23 // Rollback on error 24 queryClient.setQueryData( 25 ['users', updatedUser.id], 26 context?.previousUser 27 ) 28 }, 29 onSettled: (data, error, variables) => { 30 // Always refetch to ensure cache is in sync 31 queryClient.invalidateQueries({ queryKey: ['users', variables.id] }) 32 } 33 }) 34}

Infinite Query#

Load paginated data with infinite scroll.

1// hooks/usePosts.ts 2import { useInfiniteQuery } from '@tanstack/react-query' 3 4interface PostsResponse { 5 posts: Post[] 6 nextCursor: string | null 7} 8 9async function fetchPosts({ pageParam }: { pageParam?: string }): Promise<PostsResponse> { 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'], 21 queryFn: fetchPosts, 22 initialPageParam: undefined, 23 getNextPageParam: (lastPage) => lastPage.nextCursor 24 }) 25} 26 27// Usage 28function PostFeed() { 29 const { 30 data, 31 fetchNextPage, 32 hasNextPage, 33 isFetchingNextPage 34 } = useInfinitePosts() 35 36 return ( 37 <div> 38 {data?.pages.map((page, i) => ( 39 <Fragment key={i}> 40 {page.posts.map(post => ( 41 <PostCard key={post.id} post={post} /> 42 ))} 43 </Fragment> 44 ))} 45 46 <button 47 onClick={() => fetchNextPage()} 48 disabled={!hasNextPage || isFetchingNextPage} 49 > 50 {isFetchingNextPage ? 'Loading...' : hasNextPage ? 'Load More' : 'No more posts'} 51 </button> 52 </div> 53 ) 54}

Prefetching#

Load data before it's needed.

1// Prefetch on hover 2function UserLink({ userId }: { userId: string }) { 3 const queryClient = useQueryClient() 4 5 const prefetchUser = () => { 6 queryClient.prefetchQuery({ 7 queryKey: ['users', userId], 8 queryFn: () => fetchUser(userId) 9 }) 10 } 11 12 return ( 13 <Link href={`/users/${userId}`} onMouseEnter={prefetchUser}> 14 View User 15 </Link> 16 ) 17} 18 19// Prefetch in Server Component 20import { dehydrate, HydrationBoundary, QueryClient } from '@tanstack/react-query' 21 22async function UserPage({ params }: { params: { id: string } }) { 23 const queryClient = new QueryClient() 24 25 await queryClient.prefetchQuery({ 26 queryKey: ['users', params.id], 27 queryFn: () => fetchUser(params.id) 28 }) 29 30 return ( 31 <HydrationBoundary state={dehydrate(queryClient)}> 32 <UserProfile userId={params.id} /> 33 </HydrationBoundary> 34 ) 35}

Best Practices#

  1. Use consistent query keys - Establish naming conventions for cache keys
  2. Set appropriate stale times - Balance freshness vs performance
  3. Handle loading and error states - Always provide feedback
  4. Invalidate strategically - Don't over-invalidate, be specific
  5. Combine with Zustand - Use React Query for server state, Zustand for UI state
  • Zustand - Client state management
  • Tables - Data tables with React Query
  • Forms - Form submission with mutations