TanStack Query (React Query) — data fetching, caching, mutations, optimistic updates, infinite queries.
Local repo:
repos/tanstack-queryPackages:repos/tanstack-query/packages/react-query/src/Docs:repos/tanstack-query/docs/
npm install @tanstack/react-query
// app/providers.tsx
'use client'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { ReactQueryDevtools } from '@tanstack/react-query-devtools'
import { useState } from 'react'
export function Providers({ children }: { children: React.ReactNode }) {
const [queryClient] = useState(
() =>
new QueryClient({
defaultOptions: {
queries: {
staleTime: 60 * 1000, // 1 minute before data is "stale"
gcTime: 5 * 60 * 1000, // 5 minutes before unused cache is garbage collected
retry: 1, // retry failed queries once
refetchOnWindowFocus: false, // disable refetch on tab focus (optional)
},
},
})
)
return (
<QueryClientProvider client={queryClient}>
{children}
<ReactQueryDevtools initialIsOpen={false} />
</QueryClientProvider>
)
}
// app/layout.tsx
import { Providers } from './providers'
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html>
<body>
<Providers>{children}</Providers>
</body>
</html>
)
}
import { useQuery } from '@tanstack/react-query'
import { createClient } from '@/lib/supabase/client'
function PostsList() {
const supabase = createClient()
const { data, isLoading, isError, error, isFetching } = useQuery({
queryKey: ['posts'],
queryFn: async () => {
const { data, error } = await supabase
.from('posts')
.select('*')
.order('created_at', { ascending: false })
if (error) throw error
return data
},
})
if (isLoading) return <div>Loading...</div>
if (isError) return <div>Error: {error.message}</div>
return (
<ul>
{data.map((post) => (
<li key={post.id}>{post.title}</li>
))}
</ul>
)
}
// Simple list
queryKey: ['posts']
// Filtered list
queryKey: ['posts', { status: 'published', author: userId }]
// Single item
queryKey: ['posts', postId]
// Nested resource
queryKey: ['posts', postId, 'comments']
// With pagination
queryKey: ['posts', { page: 1, limit: 20 }]
Query keys are hierarchical — invalidating ['posts'] also invalidates
['posts', postId] and ['posts', { status: 'published' }].
const { data } = useQuery({
queryKey: ['posts', postId],
queryFn: () => fetchPost(postId),
// Only run when postId is truthy
enabled: !!postId,
// Transform data before caching
select: (data) => data.filter((post) => post.is_published),
// Show while loading (doesn't affect isLoading state like initialData does)
placeholderData: previousData, // or keepPreviousData for pagination
// Data is fresh for 5 minutes
staleTime: 5 * 60 * 1000,
// Refetch every 30 seconds
refetchInterval: 30 * 1000,
// Stop refetching when tab is not visible
refetchIntervalInBackground: false,
})
import { useMutation, useQueryClient } from '@tanstack/react-query'
function CreatePostForm() {
const supabase = createClient()
const queryClient = useQueryClient()
const createPost = useMutation({
mutationFn: async (newPost: { title: string; content: string }) => {
const { data, error } = await supabase
.from('posts')
.insert(newPost)
.select()
.single()
if (error) throw error
return data
},
onSuccess: (data) => {
// Invalidate and refetch posts list
queryClient.invalidateQueries({ queryKey: ['posts'] })
// Or add the new post directly to cache
// queryClient.setQueryData(['posts', data.id], data)
},
onError: (error) => {
console.error('Failed to create post:', error)
toast.error('Failed to create post')
},
onSettled: () => {
// Runs after success OR error — good for cleanup
},
})
return (
<form
onSubmit={(e) => {
e.preventDefault()
const formData = new FormData(e.currentTarget)
createPost.mutate({
title: formData.get('title') as string,
content: formData.get('content') as string,
})
}}
>
<input name="title" required />
<textarea name="content" required />
<button type="submit" disabled={createPost.isPending}>
{createPost.isPending ? 'Creating...' : 'Create Post'}
</button>
</form>
)
}
const deletePost = useMutation({
mutationFn: async (postId: string) => {
const { error } = await supabase
.from('posts')
.delete()
.eq('id', postId)
if (error) throw error
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['posts'] })
},
})
// Usage: deletePost.mutate(postId)
const updatePost = useMutation({
mutationFn: async ({ id, title }: { id: string; title: string }) => {
const { data, error } = await supabase
.from('posts')
.update({ title })
.eq('id', id)
.select()
.single()
if (error) throw error
return data
},
onMutate: async (newData) => {
// Cancel outgoing refetches so they don't overwrite our optimistic update
await queryClient.cancelQueries({ queryKey: ['posts', newData.id] })
// Snapshot previous value
const previousPost = queryClient.getQueryData(['posts', newData.id])
// Optimistically update cache
queryClient.setQueryData(['posts', newData.id], (old: Post) => ({
...old,
title: newData.title,
}))
// Return context with snapshot for rollback
return { previousPost }
},
onError: (err, newData, context) => {
// Rollback on error
queryClient.setQueryData(
['posts', newData.id],
context?.previousPost
)
toast.error('Failed to update post')
},
onSettled: (data, error, variables) => {
// Always refetch to ensure server state
queryClient.invalidateQueries({ queryKey: ['posts', variables.id] })
},
})
onMutate: async (newPost) => {
await queryClient.cancelQueries({ queryKey: ['posts'] })
const previousPosts = queryClient.getQueryData<Post[]>(['posts'])
queryClient.setQueryData<Post[]>(['posts'], (old) => {
if (!old) return [newPost]
return [{ ...newPost, id: 'temp-id' }, ...old]
})
return { previousPosts }
},
onError: (err, newPost, context) => {
queryClient.setQueryData(['posts'], context?.previousPosts)
},
import { useInfiniteQuery } from '@tanstack/react-query'
function InfinitePostsList() {
const supabase = createClient()
const {
data,
fetchNextPage,
hasNextPage,
isFetchingNextPage,
isLoading,
} = useInfiniteQuery({
queryKey: ['posts', 'infinite'],
queryFn: async ({ pageParam }) => {
const limit = 20
let query = supabase
.from('posts')
.select('*')
.order('created_at', { ascending: false })
.limit(limit)
if (pageParam) {
query = query.lt('created_at', pageParam)
}
const { data, error } = await query
if (error) throw error
return data
},
initialPageParam: null as string | null,
getNextPageParam: (lastPage) => {
if (lastPage.length < 20) return undefined // no more pages
return lastPage[lastPage.length - 1].created_at
},
})
const allPosts = data?.pages.flatMap((page) => page) ?? []
return (
<div>
{allPosts.map((post) => (
<PostCard key={post.id} post={post} />
))}
{hasNextPage && (
<button
onClick={() => fetchNextPage()}
disabled={isFetchingNextPage}
>
{isFetchingNextPage ? 'Loading more...' : 'Load More'}
</button>
)}
</div>
)
}
const { data, fetchNextPage, hasNextPage } = useInfiniteQuery({
queryKey: ['posts', 'paginated'],
queryFn: async ({ pageParam }) => {
const limit = 20
const offset = pageParam * limit
const { data, error, count } = await supabase
.from('posts')
.select('*', { count: 'exact' })
.order('created_at', { ascending: false })
.range(offset, offset + limit - 1)
if (error) throw error
return { data, count }
},
initialPageParam: 0,
getNextPageParam: (lastPage, allPages) => {
const totalFetched = allPages.length * 20
if (totalFetched >= (lastPage.count ?? 0)) return undefined
return allPages.length
},
})
// app/posts/page.tsx (Server Component)
import {
dehydrate,
HydrationBoundary,
QueryClient,
} from '@tanstack/react-query'
import { createClient } from '@/lib/supabase/server'
import { PostsList } from './posts-list'
export default async function PostsPage() {
const queryClient = new QueryClient()
const supabase = await createClient()
await queryClient.prefetchQuery({
queryKey: ['posts'],
queryFn: async () => {
const { data, error } = await supabase
.from('posts')
.select('*')
.order('created_at', { ascending: false })
if (error) throw error
return data
},
})
return (
<HydrationBoundary state={dehydrate(queryClient)}>
<PostsList />
</HydrationBoundary>
)
}
// app/posts/posts-list.tsx (Client Component)
'use client'
import { useQuery } from '@tanstack/react-query'
export function PostsList() {
// This will use the prefetched data — no loading state on first render
const { data } = useQuery({
queryKey: ['posts'],
queryFn: fetchPosts,
})
return <ul>{data?.map((p) => <li key={p.id}>{p.title}</li>)}</ul>
}
function PostLink({ postId }: { postId: string }) {
const queryClient = useQueryClient()
return (
<Link
href={`/posts/${postId}`}
onMouseEnter={() => {
queryClient.prefetchQuery({
queryKey: ['posts', postId],
queryFn: () => fetchPost(postId),
staleTime: 60 * 1000,
})
}}
>
View Post
</Link>
)
}
'use client'
import { useEffect } from 'react'
import { useQueryClient } from '@tanstack/react-query'
import { createClient } from '@/lib/supabase/client'
export function RealtimeInvalidator({ table }: { table: string }) {
const queryClient = useQueryClient()
const supabase = createClient()
useEffect(() => {
const channel = supabase
.channel(`${table}-changes`)
.on(
'postgres_changes',
{ event: '*', schema: 'public', table },
() => {
queryClient.invalidateQueries({ queryKey: [table] })
}
)
.subscribe()
return () => {
supabase.removeChannel(channel)
}
}, [table, supabase, queryClient])
return null
}
// Usage in layout:
// <RealtimeInvalidator table="posts" />
// lib/queries.ts
import { createClient } from '@/lib/supabase/client'
import type { Database } from '@/types/database.types'
type Tables = Database['public']['Tables']
export function createQueryFn<T extends keyof Tables>(table: T) {
return async () => {
const supabase = createClient()
const { data, error } = await supabase.from(table).select('*')
if (error) throw error
return data
}
}
// Usage:
// useQuery({ queryKey: ['posts'], queryFn: createQueryFn('posts') })
// When creating a comment, invalidate both comments and the parent post
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['posts', postId, 'comments'] })
queryClient.invalidateQueries({ queryKey: ['posts', postId] }) // comment count changed
}
// Fetch user first, then their posts
const { data: user } = useQuery({
queryKey: ['user'],
queryFn: fetchUser,
})
const { data: posts } = useQuery({
queryKey: ['posts', { userId: user?.id }],
queryFn: () => fetchUserPosts(user!.id),
enabled: !!user?.id, // only runs when user is loaded
})
const queryClient = new QueryClient({
defaultOptions: {
queries: {
retry: (failureCount, error) => {
// Don't retry on 401/403
if (error instanceof Error && error.message.includes('401')) return false
return failureCount < 2
},
},
mutations: {
onError: (error) => {
toast.error(error.message)
},
},
},
})