Skip to Content
Grab N GoReact Query

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 data
useMutation
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 reference
Query 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 to

Beginner 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 auth
Refetch
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 error
Dependent 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