tRPC React Query Testing: Hooks, Mutations, and Optimistic Updates

tRPC React Query Testing: Hooks, Mutations, and Optimistic Updates

Testing tRPC hooks means testing React Query hooks — and that means dealing with providers, async state transitions, and cache behavior. This post covers how to set up a minimal tRPC + QueryClient test wrapper, how to test loading, error, and success states with React Testing Library, how to verify optimistic updates roll back on failure, and how to confirm cache invalidation fires when mutations succeed.

Key Takeaways

Every test needs a fresh QueryClient. Shared state between tests causes flaky failures. Create a new QueryClient in beforeEach with retry:false and gcTime:0 to prevent background refetch noise.

Use msw to intercept tRPC HTTP calls. tRPC over HTTP uses predictable URL patterns. msw handlers let you simulate success, loading delay, and error responses without mocking the tRPC client itself.

Test state transitions, not just final state. A loading spinner that never appears is a UX bug. Use waitFor() and findBy*() to assert that components move through loading → success (or loading → error) in the right order.

Optimistic update tests need two HTTP handlers. First: a handler that delays so the optimistic state is visible. Then: a handler that fails so you can test the rollback. Use msw's server.use() to override handlers per test.

waitFor() with expect inside is the correct pattern. Don't use act() manually for async state — use waitFor(() => expect(screen.getByText('...')).toBeInTheDocument()) and let React Testing Library handle the flushing.

Setup: Dependencies

npm install @trpc/client @trpc/react-query @tanstack/react-query
npm install -D vitest @testing-library/react @testing-library/user-event @testing-library/jest-dom
npm install -D msw@2 whatwg-fetch

tRPC's React Query integration (@trpc/react-query) wraps TanStack Query hooks. Testing these hooks is fundamentally testing TanStack Query behavior with your tRPC client wired in.

Test Wrapper Setup

Every component test that uses tRPC hooks needs a QueryClient provider and a tRPC provider. Extract this into a reusable wrapper factory:

// test/helpers/wrapper.tsx
import React from 'react'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { httpBatchLink } from '@trpc/client'
import { trpc } from '../../src/lib/trpc'  // your createTRPCReact instance

export function createTestQueryClient() {
  return new QueryClient({
    defaultOptions: {
      queries: {
        retry: false,           // don't retry failed queries in tests
        gcTime: 0,              // don't keep stale cache between tests
        staleTime: 0,           // always refetch in tests
      },
      mutations: {
        retry: false,
      },
    },
  })
}

interface WrapperProps {
  children: React.ReactNode
  queryClient?: QueryClient
}

export function createWrapper(queryClient = createTestQueryClient()) {
  const trpcClient = trpc.createClient({
    links: [
      httpBatchLink({
        url: 'http://localhost:3000/api/trpc',
        // fetch is intercepted by msw
      }),
    ],
  })

  function Wrapper({ children }: WrapperProps) {
    return (
      <trpc.Provider client={trpcClient} queryClient={queryClient}>
        <QueryClientProvider client={queryClient}>
          {children}
        </QueryClientProvider>
      </trpc.Provider>
    )
  }

  return { Wrapper, queryClient }
}

Setting Up MSW

// test/mocks/handlers.ts
import { http, HttpResponse } from 'msw'

export const handlers = [
  http.get('http://localhost:3000/api/trpc/users.list', () => {
    return HttpResponse.json({
      result: { data: [{ id: '1', name: 'Alice', email: 'alice@example.com' }] },
    })
  }),

  http.post('http://localhost:3000/api/trpc/users.create', () => {
    return HttpResponse.json({
      result: { data: { id: '2', name: 'Bob', email: 'bob@example.com' } },
    })
  }),
]
// test/setup.ts  (vitest setup file)
import { setupServer } from 'msw/node'
import { handlers } from './mocks/handlers'
import '@testing-library/jest-dom'

export const server = setupServer(...handlers)

beforeAll(() => server.listen({ onUnhandledRequest: 'error' }))
afterEach(() => server.resetHandlers())
afterAll(() => server.close())
// vitest.config.ts
export default {
  test: {
    environment: 'jsdom',
    setupFiles: ['./test/setup.ts'],
    globals: true,
  },
}

Testing a useQuery Hook: Loading, Success, Error States

// src/components/UserList.tsx
import { trpc } from '../lib/trpc'

export function UserList() {
  const { data, isLoading, isError, error } = trpc.users.list.useQuery()

  if (isLoading) return <p>Loading users...</p>
  if (isError) return <p>Error: {error.message}</p>

  return (
    <ul>
      {data?.map(user => (
        <li key={user.id}>{user.name} — {user.email}</li>
      ))}
    </ul>
  )
}
// test/components/UserList.test.tsx
import { describe, it, expect } from 'vitest'
import { render, screen, waitFor } from '@testing-library/react'
import { http, HttpResponse } from 'msw'
import { server } from '../setup'
import { UserList } from '../../src/components/UserList'
import { createWrapper } from '../helpers/wrapper'

describe('UserList', () => {
  it('shows loading state initially', async () => {
    // Delay the response so we can catch the loading state
    server.use(
      http.get('http://localhost:3000/api/trpc/users.list', async () => {
        await new Promise(r => setTimeout(r, 100))
        return HttpResponse.json({ result: { data: [] } })
      })
    )

    const { Wrapper } = createWrapper()
    render(<UserList />, { wrapper: Wrapper })

    expect(screen.getByText('Loading users...')).toBeInTheDocument()
  })

  it('renders users after successful fetch', async () => {
    const { Wrapper } = createWrapper()
    render(<UserList />, { wrapper: Wrapper })

    await waitFor(() => {
      expect(screen.getByText('Alice — alice@example.com')).toBeInTheDocument()
    })
  })

  it('shows error state when the query fails', async () => {
    server.use(
      http.get('http://localhost:3000/api/trpc/users.list', () => {
        return HttpResponse.json(
          { error: { message: 'Internal server error', code: -32603 } },
          { status: 500 }
        )
      })
    )

    const { Wrapper } = createWrapper()
    render(<UserList />, { wrapper: Wrapper })

    await waitFor(() => {
      expect(screen.getByText(/Error:/)).toBeInTheDocument()
    })
  })

  it('renders empty list when no users returned', async () => {
    server.use(
      http.get('http://localhost:3000/api/trpc/users.list', () => {
        return HttpResponse.json({ result: { data: [] } })
      })
    )

    const { Wrapper } = createWrapper()
    render(<UserList />, { wrapper: Wrapper })

    await waitFor(() => {
      const listItems = screen.queryAllByRole('listitem')
      expect(listItems).toHaveLength(0)
    })
  })
})

Testing useMutation

// src/components/CreateUserForm.tsx
import { useState } from 'react'
import { trpc } from '../lib/trpc'

export function CreateUserForm() {
  const [name, setName] = useState('')
  const [email, setEmail] = useState('')

  const createUser = trpc.users.create.useMutation({
    onSuccess: () => {
      setName('')
      setEmail('')
    },
  })

  return (
    <form onSubmit={e => {
      e.preventDefault()
      createUser.mutate({ name, email })
    }}>
      <input aria-label="Name" value={name} onChange={e => setName(e.target.value)} />
      <input aria-label="Email" value={email} onChange={e => setEmail(e.target.value)} />
      <button type="submit" disabled={createUser.isPending}>
        {createUser.isPending ? 'Creating...' : 'Create User'}
      </button>
      {createUser.isError && <p role="alert">Failed to create user</p>}
      {createUser.isSuccess && <p>User created!</p>}
    </form>
  )
}
// test/components/CreateUserForm.test.tsx
import { describe, it, expect } from 'vitest'
import { render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { http, HttpResponse } from 'msw'
import { server } from '../setup'
import { CreateUserForm } from '../../src/components/CreateUserForm'
import { createWrapper } from '../helpers/wrapper'

describe('CreateUserForm', () => {
  it('shows pending state during submission', async () => {
    server.use(
      http.post('http://localhost:3000/api/trpc/users.create', async () => {
        await new Promise(r => setTimeout(r, 200))
        return HttpResponse.json({ result: { data: { id: '2', name: 'Bob', email: 'bob@test.com' } } })
      })
    )

    const { Wrapper } = createWrapper()
    render(<CreateUserForm />, { wrapper: Wrapper })

    await userEvent.type(screen.getByLabelText('Name'), 'Bob')
    await userEvent.type(screen.getByLabelText('Email'), 'bob@test.com')
    await userEvent.click(screen.getByRole('button', { name: 'Create User' }))

    expect(screen.getByRole('button', { name: 'Creating...' })).toBeDisabled()
  })

  it('shows success message after creation', async () => {
    const { Wrapper } = createWrapper()
    render(<CreateUserForm />, { wrapper: Wrapper })

    await userEvent.type(screen.getByLabelText('Name'), 'Bob')
    await userEvent.type(screen.getByLabelText('Email'), 'bob@test.com')
    await userEvent.click(screen.getByRole('button', { name: 'Create User' }))

    await waitFor(() => {
      expect(screen.getByText('User created!')).toBeInTheDocument()
    })
  })

  it('clears form fields after successful creation', async () => {
    const { Wrapper } = createWrapper()
    render(<CreateUserForm />, { wrapper: Wrapper })

    await userEvent.type(screen.getByLabelText('Name'), 'Bob')
    await userEvent.type(screen.getByLabelText('Email'), 'bob@test.com')
    await userEvent.click(screen.getByRole('button', { name: 'Create User' }))

    await waitFor(() => {
      expect(screen.getByLabelText<HTMLInputElement>('Name').value).toBe('')
      expect(screen.getByLabelText<HTMLInputElement>('Email').value).toBe('')
    })
  })

  it('shows error message on mutation failure', async () => {
    server.use(
      http.post('http://localhost:3000/api/trpc/users.create', () => {
        return HttpResponse.json({ error: { message: 'Email taken', code: -32600 } }, { status: 400 })
      })
    )

    const { Wrapper } = createWrapper()
    render(<CreateUserForm />, { wrapper: Wrapper })

    await userEvent.type(screen.getByLabelText('Name'), 'Bob')
    await userEvent.type(screen.getByLabelText('Email'), 'bob@test.com')
    await userEvent.click(screen.getByRole('button', { name: 'Create User' }))

    await waitFor(() => {
      expect(screen.getByRole('alert')).toBeInTheDocument()
    })
  })
})

Testing Optimistic Updates

// src/components/TodoList.tsx (with optimistic updates)
import { trpc } from '../lib/trpc'

export function TodoList() {
  const utils = trpc.useUtils()
  const { data: todos = [] } = trpc.todos.list.useQuery()

  const addTodo = trpc.todos.create.useMutation({
    onMutate: async (newTodo) => {
      await utils.todos.list.cancel()
      const previous = utils.todos.list.getData()

      utils.todos.list.setData(undefined, (old = []) => [
        ...old,
        { id: 'temp-id', text: newTodo.text, done: false },
      ])

      return { previous }
    },
    onError: (_err, _newTodo, context) => {
      utils.todos.list.setData(undefined, context?.previous)
    },
    onSettled: () => {
      utils.todos.list.invalidate()
    },
  })

  return (
    <div>
      <ul>
        {todos.map(t => <li key={t.id}>{t.text}</li>)}
      </ul>
      <button onClick={() => addTodo.mutate({ text: 'New todo' })}>Add</button>
    </div>
  )
}
// test/components/TodoList.test.tsx
import { describe, it, expect, vi } from 'vitest'
import { render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { http, HttpResponse } from 'msw'
import { server } from '../setup'
import { TodoList } from '../../src/components/TodoList'
import { createWrapper } from '../helpers/wrapper'

describe('TodoList — optimistic updates', () => {
  it('shows new todo optimistically before server confirms', async () => {
    // Delay server response so optimistic item appears first
    server.use(
      http.get('http://localhost:3000/api/trpc/todos.list', () =>
        HttpResponse.json({ result: { data: [] } })
      ),
      http.post('http://localhost:3000/api/trpc/todos.create', async () => {
        await new Promise(r => setTimeout(r, 300))
        return HttpResponse.json({ result: { data: { id: '1', text: 'New todo', done: false } } })
      })
    )

    const { Wrapper } = createWrapper()
    render(<TodoList />, { wrapper: Wrapper })

    await waitFor(() => screen.getByRole('button', { name: 'Add' }))
    await userEvent.click(screen.getByRole('button', { name: 'Add' }))

    // Optimistic item should appear immediately, before server responds
    expect(screen.getByText('New todo')).toBeInTheDocument()
  })

  it('rolls back optimistic update on server error', async () => {
    server.use(
      http.get('http://localhost:3000/api/trpc/todos.list', () =>
        HttpResponse.json({ result: { data: [{ id: 'existing', text: 'Existing todo', done: false }] } })
      ),
      http.post('http://localhost:3000/api/trpc/todos.create', () =>
        HttpResponse.json({ error: { message: 'Server error', code: -32603 } }, { status: 500 })
      )
    )

    const { Wrapper } = createWrapper()
    render(<TodoList />, { wrapper: Wrapper })

    await waitFor(() => screen.getByText('Existing todo'))
    await userEvent.click(screen.getByRole('button', { name: 'Add' }))

    // After error, optimistic item should be removed, only original remains
    await waitFor(() => {
      expect(screen.queryByText('New todo')).not.toBeInTheDocument()
      expect(screen.getByText('Existing todo')).toBeInTheDocument()
    })
  })
})

Testing Cache Invalidation

describe('cache invalidation after mutation', () => {
  it('refetches the list after successful creation', async () => {
    let fetchCount = 0

    server.use(
      http.get('http://localhost:3000/api/trpc/todos.list', () => {
        fetchCount++
        return HttpResponse.json({ result: { data: [] } })
      }),
      http.post('http://localhost:3000/api/trpc/todos.create', () =>
        HttpResponse.json({ result: { data: { id: '1', text: 'New todo', done: false } } })
      )
    )

    const { Wrapper } = createWrapper()
    render(<TodoList />, { wrapper: Wrapper })

    // Wait for initial fetch
    await waitFor(() => expect(fetchCount).toBe(1))

    await userEvent.click(screen.getByRole('button', { name: 'Add' }))

    // After mutation succeeds, onSettled calls invalidate() → triggers refetch
    await waitFor(() => expect(fetchCount).toBe(2))
  })
})

What to Test vs. What to Skip

Test:

  • Loading state is shown before data arrives
  • Success state renders the correct data
  • Error state is shown and contains a user-readable message
  • Mutation pending state disables interactive elements
  • Success callback side effects (form reset, success message, navigation)
  • Optimistic update appears immediately before server confirms
  • Optimistic update rolls back when mutation fails
  • Cache invalidation triggers a refetch after successful mutation

Skip:

  • Testing that React Query itself manages cache correctly — that's TanStack Query's test suite
  • Testing tRPC serialization or type safety at the HTTP level — use createCaller() tests for that
  • Asserting exact fetch URLs or request bodies in component tests — test behavior, not implementation
  • Testing every possible network error code — one representative error response per component is enough
  • Mocking the tRPC client internals — test through msw HTTP interception instead, which is closer to production behavior

The goal for component tests is confidence in UI behavior: does the loading spinner appear, does the error message show, does the form clear on success. For business logic, use createCaller() tests. Keep the boundary clean.

Read more