TanStack Query v5 Testing: Custom Hooks, Invalidation, Optimistic Updates, and Offline Mode
TanStack Query v5 handles server state — fetching, caching, synchronizing, and updating data from your API. Every meaningful UI state it manages (loading, error, success, stale, refetching) is a testing opportunity.
This guide covers testing TanStack Query v5: custom query and mutation hooks, cache invalidation, optimistic updates, and offline mode behaviour.
Setup
npm install @tanstack/react-query
npm install -D vitest @vitejs/plugin-react jsdom @testing-library/react @testing-library/react-hooks @testing-library/user-event @testing-library/jest-domYou need a fresh QueryClient per test to prevent cache leakage between tests:
// src/test/query-utils.ts
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { render } from '@testing-library/react'
import type { ReactNode } from 'react'
export function createTestQueryClient() {
return new QueryClient({
defaultOptions: {
queries: {
retry: false, // Don't retry in tests
gcTime: Infinity, // Don't garbage collect during tests
},
},
})
}
export function renderWithQueryClient(
ui: ReactNode,
queryClient = createTestQueryClient()
) {
function Wrapper({ children }: { children: ReactNode }) {
return <QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
}
return { ...render(ui, { wrapper: Wrapper }), queryClient }
}Testing Custom Query Hooks
Separate your query logic into custom hooks and test them directly:
// hooks/useUser.ts
import { useQuery } from '@tanstack/react-query'
interface User {
id: string
name: string
email: string
plan: 'free' | 'pro'
}
async function fetchUser(userId: string): Promise<User> {
const res = await fetch(`/api/users/${userId}`)
if (!res.ok) throw new Error('Failed to fetch user')
return res.json()
}
export function useUser(userId: string) {
return useQuery({
queryKey: ['users', userId],
queryFn: () => fetchUser(userId),
enabled: !!userId,
})
}// hooks/useUser.test.tsx
import { renderHook, waitFor } from '@testing-library/react'
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
import { QueryClientProvider } from '@tanstack/react-query'
import { useUser } from './useUser'
import { createTestQueryClient } from '@/test/query-utils'
describe('useUser', () => {
beforeEach(() => {
global.fetch = vi.fn()
})
afterEach(() => {
vi.restoreAllMocks()
})
function wrapper({ children }: { children: React.ReactNode }) {
return (
<QueryClientProvider client={createTestQueryClient()}>
{children}
</QueryClientProvider>
)
}
it('fetches user data successfully', async () => {
vi.mocked(fetch).mockResolvedValue({
ok: true,
json: async () => ({ id: 'u1', name: 'Alice Chen', email: 'alice@example.com', plan: 'pro' }),
} as Response)
const { result } = renderHook(() => useUser('u1'), { wrapper })
await waitFor(() => expect(result.current.isSuccess).toBe(true))
expect(result.current.data?.name).toBe('Alice Chen')
expect(result.current.data?.plan).toBe('pro')
})
it('returns error state when fetch fails', async () => {
vi.mocked(fetch).mockResolvedValue({
ok: false,
status: 404,
} as Response)
const { result } = renderHook(() => useUser('u1'), { wrapper })
await waitFor(() => expect(result.current.isError).toBe(true))
expect(result.current.error).toBeDefined()
})
it('is disabled when userId is empty', () => {
const { result } = renderHook(() => useUser(''), { wrapper })
expect(result.current.fetchStatus).toBe('idle')
expect(result.current.isLoading).toBe(false)
})
it('shows loading state initially', () => {
vi.mocked(fetch).mockReturnValue(new Promise(() => {}))
const { result } = renderHook(() => useUser('u1'), { wrapper })
expect(result.current.isLoading).toBe(true)
})
})Testing Mutations
Mutations are tested through their effect on the UI and the cache:
// hooks/useUpdateUser.ts
import { useMutation, useQueryClient } from '@tanstack/react-query'
interface UpdateUserPayload {
userId: string
name: string
email: string
}
async function updateUser(payload: UpdateUserPayload) {
const res = await fetch(`/api/users/${payload.userId}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name: payload.name, email: payload.email }),
})
if (!res.ok) throw new Error('Update failed')
return res.json()
}
export function useUpdateUser() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: updateUser,
onSuccess: (data, variables) => {
// Update the cache directly
queryClient.setQueryData(['users', variables.userId], data)
// Invalidate related queries
queryClient.invalidateQueries({ queryKey: ['users'] })
},
})
}// hooks/useUpdateUser.test.tsx
import { renderHook, waitFor } from '@testing-library/react'
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { QueryClientProvider } from '@tanstack/react-query'
import { useUpdateUser } from './useUpdateUser'
import { createTestQueryClient } from '@/test/query-utils'
describe('useUpdateUser', () => {
let queryClient: ReturnType<typeof createTestQueryClient>
beforeEach(() => {
queryClient = createTestQueryClient()
global.fetch = vi.fn()
})
function wrapper({ children }: { children: React.ReactNode }) {
return <QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
}
it('updates cache after successful mutation', async () => {
// Seed the cache with initial data
queryClient.setQueryData(['users', 'u1'], {
id: 'u1', name: 'Alice', email: 'alice@old.com', plan: 'pro',
})
vi.mocked(fetch).mockResolvedValue({
ok: true,
json: async () => ({ id: 'u1', name: 'Alice Chen', email: 'alice@new.com', plan: 'pro' }),
} as Response)
const { result } = renderHook(() => useUpdateUser(), { wrapper })
result.current.mutate({ userId: 'u1', name: 'Alice Chen', email: 'alice@new.com' })
await waitFor(() => expect(result.current.isSuccess).toBe(true))
const cached = queryClient.getQueryData(['users', 'u1']) as { name: string }
expect(cached.name).toBe('Alice Chen')
})
it('reports error state on failed mutation', async () => {
vi.mocked(fetch).mockResolvedValue({ ok: false } as Response)
const { result } = renderHook(() => useUpdateUser(), { wrapper })
result.current.mutate({ userId: 'u1', name: 'Alice', email: 'alice@example.com' })
await waitFor(() => expect(result.current.isError).toBe(true))
expect(result.current.error).toBeDefined()
})
})Testing Query Invalidation
Query invalidation forces a refetch. Test that the right queries are invalidated after an action:
// hooks/useDeleteUser.test.tsx
import { renderHook, waitFor } from '@testing-library/react'
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { QueryClientProvider } from '@tanstack/react-query'
import { createTestQueryClient } from '@/test/query-utils'
// Minimal hook for testing invalidation
function useDeleteUser() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: async (userId: string) => {
const res = await fetch(`/api/users/${userId}`, { method: 'DELETE' })
if (!res.ok) throw new Error('Delete failed')
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['users'] })
},
})
}
describe('useDeleteUser invalidation', () => {
it('marks user list as stale after deletion', async () => {
const queryClient = createTestQueryClient()
global.fetch = vi.fn().mockResolvedValue({ ok: true, json: async () => ({}) } as Response)
// Seed the user list as fresh
queryClient.setQueryData(['users'], [{ id: 'u1', name: 'Alice' }])
function wrapper({ children }: { children: React.ReactNode }) {
return <QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
}
const { result } = renderHook(() => useDeleteUser(), { wrapper })
const invalidateSpy = vi.spyOn(queryClient, 'invalidateQueries')
result.current.mutate('u1')
await waitFor(() => expect(result.current.isSuccess).toBe(true))
expect(invalidateSpy).toHaveBeenCalledWith({ queryKey: ['users'] })
})
})Testing Optimistic Updates
Optimistic updates change the UI before the server responds. Test both the optimistic state and rollback on failure:
// hooks/useLikePost.ts
import { useMutation, useQueryClient } from '@tanstack/react-query'
interface Post {
id: string
likeCount: number
liked: boolean
}
export function useLikePost(postId: string) {
const queryClient = useQueryClient()
return useMutation({
mutationFn: async () => {
const res = await fetch(`/api/posts/${postId}/like`, { method: 'POST' })
if (!res.ok) throw new Error('Like failed')
return res.json()
},
onMutate: async () => {
await queryClient.cancelQueries({ queryKey: ['posts', postId] })
const previous = queryClient.getQueryData<Post>(['posts', postId])
// Optimistic update
queryClient.setQueryData<Post>(['posts', postId], (old) => {
if (!old) return old
return { ...old, liked: true, likeCount: old.likeCount + 1 }
})
return { previous }
},
onError: (_err, _variables, context) => {
// Rollback
if (context?.previous) {
queryClient.setQueryData(['posts', postId], context.previous)
}
},
})
}describe('useLikePost optimistic updates', () => {
it('shows optimistic like before server responds', async () => {
const queryClient = createTestQueryClient()
queryClient.setQueryData(['posts', 'p1'], { id: 'p1', likeCount: 10, liked: false })
let resolveRequest!: (value: unknown) => void
global.fetch = vi.fn().mockReturnValue(
new Promise((resolve) => { resolveRequest = resolve })
)
function wrapper({ children }: { children: React.ReactNode }) {
return <QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
}
const { result } = renderHook(() => useLikePost('p1'), { wrapper })
result.current.mutate()
await waitFor(() => {
const data = queryClient.getQueryData<Post>(['posts', 'p1'])
expect(data?.liked).toBe(true)
expect(data?.likeCount).toBe(11)
})
// Now resolve the server response
resolveRequest({ ok: true, json: async () => ({ id: 'p1', likeCount: 11, liked: true }) })
await waitFor(() => expect(result.current.isSuccess).toBe(true))
})
it('rolls back on server error', async () => {
const queryClient = createTestQueryClient()
queryClient.setQueryData(['posts', 'p1'], { id: 'p1', likeCount: 10, liked: false })
global.fetch = vi.fn().mockResolvedValue({ ok: false } as Response)
function wrapper({ children }: { children: React.ReactNode }) {
return <QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
}
const { result } = renderHook(() => useLikePost('p1'), { wrapper })
result.current.mutate()
await waitFor(() => expect(result.current.isError).toBe(true))
const data = queryClient.getQueryData<Post>(['posts', 'p1'])
expect(data?.liked).toBe(false)
expect(data?.likeCount).toBe(10)
})
})Testing Offline Mode
TanStack Query pauses queries when the browser is offline. Test this using navigator.onLine:
describe('useUser offline mode', () => {
it('does not fetch when offline', async () => {
global.fetch = vi.fn()
vi.spyOn(navigator, 'onLine', 'get').mockReturnValue(false)
function wrapper({ children }: { children: React.ReactNode }) {
return (
<QueryClientProvider client={createTestQueryClient()}>
{children}
</QueryClientProvider>
)
}
const { result } = renderHook(() => useUser('u1'), { wrapper })
// Give it time to attempt — it shouldn't
await new Promise((resolve) => setTimeout(resolve, 100))
expect(fetch).not.toHaveBeenCalled()
expect(result.current.fetchStatus).toBe('paused')
})
})What Automated Tests Miss
Unit tests validate hook logic but don't cover:
- Real network failures — dropped connections, timeout handling
- Cache persistence across page reloads — if you use
persistQueryClient - Background refetch timing — stale time and refetch interval behaviour in production
- Race conditions — concurrent mutations on the same resource
HelpMeTest monitors your live app with scheduled browser tests. When stale cache data causes UI drift or mutations silently fail in production, automated monitors catch it.
Summary
TanStack Query v5 testing strategy:
- Query hooks → use
renderHookwith a freshQueryClientwrapper per test; mockfetch - Mutations → test
onSuccesscache updates, verifyinvalidateQueriescalls - Optimistic updates → use deferred promises to capture mid-mutation state; assert rollback on error
- Offline mode → mock
navigator.onLineand verifyfetchStatus === 'paused'
Always use retry: false in test query clients to prevent slow test suites from retries.