TanStack Query v5 Testing: Custom Hooks, Invalidation, Optimistic Updates, and Offline Mode

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-dom

You 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 renderHook with a fresh QueryClient wrapper per test; mock fetch
  • Mutations → test onSuccess cache updates, verify invalidateQueries calls
  • Optimistic updates → use deferred promises to capture mid-mutation state; assert rollback on error
  • Offline mode → mock navigator.onLine and verify fetchStatus === 'paused'

Always use retry: false in test query clients to prevent slow test suites from retries.

Read more