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-devtoolsSetup#
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#
- Use consistent query keys - Establish naming conventions for cache keys
- Set appropriate stale times - Balance freshness vs performance
- Handle loading and error states - Always provide feedback
- Invalidate strategically - Don't over-invalidate, be specific
- Combine with Zustand - Use React Query for server state, Zustand for UI state