React Query
Targeting TanStack Query v5
Syntax
QueryClient Setup
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
const queryClient = new QueryClient()
function App() {
return (
<QueryClientProvider client={queryClient}>
<MyApp />
</QueryClientProvider>
)
}useQuery
const { data, isPending, isError, error } = useQuery({
queryKey: ['todos'],
queryFn: () => fetch('/api/todos').then((r) => r.json()),
})
// queryKey — unique cache key
// queryFn — async function that returns datauseMutation
const mutation = useMutation({
mutationFn: (newTodo: Todo) =>
fetch('/api/todos', {
method: 'POST',
body: JSON.stringify(newTodo),
}).then((r) => r.json()),
onSuccess: () => {
// runs after successful mutation
},
})Query Keys
// string key
queryKey: ['todos']
// key with variable — auto-refetches when id changes
queryKey: ['todo', id]
// key with filters
queryKey: ['todos', { status: 'done', page: 1 }]
// keys are compared structurally, not by referenceQuery States
const { data, isPending, isError, error, isSuccess } = useQuery({
queryKey: ['user'],
queryFn: fetchUser,
})
// isPending — no data yet (first load)
// isError — queryFn threw
// isSuccess — data is available
// data — the resolved value (typed)staleTime vs gcTime
useQuery({
queryKey: ['todos'],
queryFn: fetchTodos,
staleTime: 5 * 60 * 1000, // 5 min — data is "fresh" this long
gcTime: 30 * 60 * 1000, // 30 min — unused cache lives this long
})
// stale data is refetched in background on next access
// gc removes cache entries nobody is subscribed toBeginner Patterns
GET Request
interface Post { id: number; title: string }
function Posts() {
const { data, isPending } = useQuery<Post[]>({
queryKey: ['posts'],
queryFn: () => fetch('/api/posts').then((r) => r.json()),
})
if (isPending) return <p>Loading...</p>
return <ul>{data?.map((p) => <li key={p.id}>{p.title}</li>)}</ul>
}POST Mutation
function AddPost() {
const mutation = useMutation({
mutationFn: (title: string) =>
fetch('/api/posts', {
method: 'POST',
body: JSON.stringify({ title }),
}).then((r) => r.json()),
})
return (
<button onClick={() => mutation.mutate('New Post')}>
{mutation.isPending ? 'Adding...' : 'Add Post'}
</button>
)
}Loading & Error
const { data, isPending, isError, error } = useQuery({
queryKey: ['user', id],
queryFn: () => fetchUser(id),
})
if (isPending) return <Spinner />
if (isError) return <p>Error: {error.message}</p>
return <Profile user={data} />Enabled Flag
// query won't run until userId is truthy
const { data } = useQuery({
queryKey: ['user', userId],
queryFn: () => fetchUser(userId),
enabled: !!userId, // false → query stays in "pending" state
})
// useful for queries that depend on user input or authRefetch
const { data, refetch } = useQuery({
queryKey: ['notifications'],
queryFn: fetchNotifications,
refetchOnWindowFocus: true, // default — refetch on tab focus
refetchInterval: 30_000, // poll every 30s
})
// manual refetch
<button onClick={() => refetch()}>Refresh</button>Intermediate Patterns
Invalidate After Mutation
const queryClient = useQueryClient()
const mutation = useMutation({
mutationFn: addTodo,
onSettled: () => {
// mark cached todos as stale → triggers refetch
queryClient.invalidateQueries({ queryKey: ['todos'] })
},
})
// this is the core "keep data fresh after mutation" pattern
// onSettled runs on both success and errorDependent Queries
// first query
const { data: user } = useQuery({
queryKey: ['user', email],
queryFn: () => fetchUser(email),
})
// second query — waits for user.id
const { data: projects } = useQuery({
queryKey: ['projects', user?.id],
queryFn: () => fetchProjects(user!.id),
enabled: !!user?.id, // only runs after user loads
})Optimistic Updates
const queryClient = useQueryClient()
const mutation = useMutation({
mutationFn: updateTodo,
onMutate: async (newTodo) => {
await queryClient.cancelQueries({ queryKey: ['todos'] })
const previous = queryClient.getQueryData<Todo[]>(['todos'])
queryClient.setQueryData<Todo[]>(['todos'], (old) =>
old?.map((t) => (t.id === newTodo.id ? { ...t, ...newTodo } : t))
)
return { previous } // context for rollback
},
onError: (_err, _todo, context) => {
queryClient.setQueryData(['todos'], context?.previous)
},
onSettled: () => queryClient.invalidateQueries({ queryKey: ['todos'] }),
})Select Transform
// transform data from cache without affecting what's stored
const { data: todoCount } = useQuery({
queryKey: ['todos'],
queryFn: fetchTodos,
select: (todos) => todos.length, // only re-renders when length changes
})
const { data: doneTodos } = useQuery({
queryKey: ['todos'],
queryFn: fetchTodos,
select: (todos) => todos.filter((t) => t.done),
})Prefetching
const queryClient = useQueryClient()
// prefetch on hover — data is in cache when user navigates
function PostLink({ id }: { id: number }) {
return (
<a
href={`/posts/${id}`}
onMouseEnter={() =>
queryClient.prefetchQuery({
queryKey: ['post', id],
queryFn: () => fetchPost(id),
})
}
>Post {id}</a>
)
}Placeholder & Initial Data
// placeholderData — shown while loading, not persisted to cache
const { data } = useQuery({
queryKey: ['todo', id],
queryFn: () => fetchTodo(id),
placeholderData: { id, title: 'Loading...', done: false },
})
// initialData — persisted to cache, counts as "fetched"
const { data } = useQuery({
queryKey: ['todo', id],
queryFn: () => fetchTodo(id),
initialData: () => queryClient
.getQueryData<Todo[]>(['todos'])
?.find((t) => t.id === id),
})Advanced Patterns
Infinite Query
const { data, fetchNextPage, hasNextPage, isFetchingNextPage } =
useInfiniteQuery({
queryKey: ['posts'],
queryFn: ({ pageParam }) =>
fetch(`/api/posts?cursor=${pageParam}`).then((r) => r.json()),
initialPageParam: 0,
getNextPageParam: (lastPage) => lastPage.nextCursor ?? undefined,
})
// data.pages — array of all fetched pages
// flatMap to render: data.pages.flatMap((p) => p.items)Mutation + Optimistic List
const queryClient = useQueryClient()
const addMutation = useMutation({
mutationFn: (title: string) => createTodo(title),
onMutate: async (title) => {
await queryClient.cancelQueries({ queryKey: ['todos'] })
const previous = queryClient.getQueryData<Todo[]>(['todos'])
const optimistic = { id: Date.now(), title, done: false }
queryClient.setQueryData<Todo[]>(['todos'], (old) =>
[...(old ?? []), optimistic]
)
return { previous }
},
onError: (_err, _title, ctx) => {
queryClient.setQueryData(['todos'], ctx?.previous) // rollback
},
onSettled: () => queryClient.invalidateQueries({ queryKey: ['todos'] }),
})Query Invalidation Strategies
const qc = useQueryClient()
// fuzzy — invalidates ['todos'], ['todos', 1], ['todos', { status }]
qc.invalidateQueries({ queryKey: ['todos'] })
// exact — only ['todos'], not ['todos', 1]
qc.invalidateQueries({ queryKey: ['todos'], exact: true })
// predicate — full control
qc.invalidateQueries({
predicate: (query) =>
query.queryKey[0] === 'todos' && query.state.data?.length > 10,
})Retry & Error Recovery
import { QueryErrorResetBoundary } from '@tanstack/react-query'
import { ErrorBoundary } from 'react-error-boundary'
// per-query retry config
useQuery({ queryKey: ['data'], queryFn: fetchData, retry: 2 })
// reset boundary — lets user retry after error
<QueryErrorResetBoundary>
{({ reset }) => (
<ErrorBoundary onReset={reset} fallbackRender={({ resetErrorBoundary }) => (
<button onClick={resetErrorBoundary}>Retry</button>
)}>
<MyComponent />
</ErrorBoundary>
)}
</QueryErrorResetBoundary>Custom useQuery Hook
// reusable typed hook — keeps queryKey + queryFn together
function useTodos(status?: 'done' | 'pending') {
return useQuery<Todo[]>({
queryKey: ['todos', { status }],
queryFn: () =>
fetch(`/api/todos?status=${status ?? ''}`).then((r) => r.json()),
staleTime: 5 * 60 * 1000,
})
}
// usage
const { data: todos, isPending } = useTodos('done')Last updated on