React 19 Testing Guide: useOptimistic, useFormStatus, and use() with Vitest
React 19 shipped a handful of hooks that change how you think about async state, form handling, and data loading. Most of your existing tests still work — but the new hooks introduce async patterns that need deliberate test strategies.
This guide covers testing useOptimistic, useFormStatus, and the new use() hook with Vitest and React Testing Library. Every pattern here is production-ready and accounts for the concurrent rendering quirks that catch teams off guard.
Setting Up Vitest for React 19
React 19 requires react-dom/client and the concurrent renderer. Vitest handles this well with the jsdom environment:
npm install -D vitest @vitejs/plugin-react jsdom @testing-library/react @testing-library/user-event @testing-library/jest-domvitest.config.ts:
import { defineConfig } from 'vitest/config'
import react from '@vitejs/plugin-react'
export default defineConfig({
plugins: [react()],
test: {
environment: 'jsdom',
globals: true,
setupFiles: ['./src/test/setup.ts'],
},
})src/test/setup.ts:
import '@testing-library/jest-dom'
import { cleanup } from '@testing-library/react'
import { afterEach } from 'vitest'
afterEach(() => {
cleanup()
})Testing useOptimistic
useOptimistic lets you show a speculative UI state while an async operation is in flight. The tricky part for tests: the optimistic state is visible immediately, then reverts to the server state once the async action settles.
Here's a like button component:
// LikeButton.tsx
import { useOptimistic, useTransition } from 'react'
interface Props {
liked: boolean
likeCount: number
onLike: () => Promise<void>
}
export function LikeButton({ liked, likeCount, onLike }: Props) {
const [optimisticState, setOptimistic] = useOptimistic(
{ liked, likeCount },
(current, newLiked: boolean) => ({
liked: newLiked,
likeCount: current.likeCount + (newLiked ? 1 : -1),
})
)
const [isPending, startTransition] = useTransition()
const handleClick = () => {
setOptimistic(!optimisticState.liked)
startTransition(async () => {
await onLike()
})
}
return (
<button onClick={handleClick} disabled={isPending}>
{optimisticState.liked ? '❤️' : '🤍'} {optimisticState.likeCount}
</button>
)
}Testing the optimistic update:
import { render, screen, act } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { describe, it, expect, vi } from 'vitest'
import { LikeButton } from './LikeButton'
describe('LikeButton', () => {
it('shows optimistic state immediately on click', async () => {
let resolveOnLike!: () => void
const onLike = vi.fn(
() => new Promise<void>((resolve) => { resolveOnLike = resolve })
)
render(<LikeButton liked={false} likeCount={10} onLike={onLike} />)
const button = screen.getByRole('button')
expect(button).toHaveTextContent('🤍 10')
await userEvent.click(button)
// Optimistic state is visible immediately
expect(button).toHaveTextContent('❤️ 11')
// Resolve the server action
await act(async () => {
resolveOnLike()
})
// State is now confirmed
expect(button).toHaveTextContent('❤️ 11')
})
it('reverts optimistic state if server action fails', async () => {
const onLike = vi.fn().mockRejectedValue(new Error('Network error'))
render(<LikeButton liked={false} likeCount={10} onLike={onLike} />)
await act(async () => {
await userEvent.click(screen.getByRole('button'))
})
// After rejection, reverts to original state
expect(screen.getByRole('button')).toHaveTextContent('🤍 10')
})
})Key insight: wrap the full interaction in act() when you need to observe the settled state. Use a deferred promise when you want to assert the optimistic state mid-flight.
Testing useFormStatus
useFormStatus reads the submission state of the nearest parent <form>. This creates a coupling: the component using useFormStatus must be rendered inside a <form> element, and that form must use a Server Action (or a function that React can treat as one in tests).
// SubmitButton.tsx
import { useFormStatus } from 'react-dom'
export function SubmitButton() {
const { pending } = useFormStatus()
return (
<button type="submit" disabled={pending}>
{pending ? 'Saving…' : 'Save'}
</button>
)
}Testing it requires wrapping in a form with an async action:
import { render, screen, act } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { describe, it, expect, vi } from 'vitest'
import { SubmitButton } from './SubmitButton'
function TestForm({ action }: { action: () => Promise<void> }) {
return (
<form action={action}>
<input name="name" defaultValue="Alice" />
<SubmitButton />
</form>
)
}
describe('SubmitButton', () => {
it('shows pending state while form submits', async () => {
let resolve!: () => void
const action = vi.fn(
() => new Promise<void>((r) => { resolve = r })
)
render(<TestForm action={action} />)
expect(screen.getByRole('button')).toHaveTextContent('Save')
expect(screen.getByRole('button')).not.toBeDisabled()
await userEvent.click(screen.getByRole('button'))
// During pending
expect(screen.getByRole('button')).toHaveTextContent('Saving…')
expect(screen.getByRole('button')).toBeDisabled()
await act(async () => { resolve() })
// After completion
expect(screen.getByRole('button')).toHaveTextContent('Save')
expect(screen.getByRole('button')).not.toBeDisabled()
})
})Common mistake: rendering <SubmitButton /> without a parent <form>. useFormStatus will always return { pending: false } because there is no form context. Your test passes, but the component is effectively untested.
Testing the use() Hook
The use() hook unwraps promises and context in a way that can be called conditionally — unlike all previous hooks. The most common use case is reading a promise from a parent (passed as a prop) inside a Suspense boundary.
// UserProfile.tsx
import { use, Suspense } from 'react'
interface User {
id: string
name: string
email: string
}
function ProfileContent({ userPromise }: { userPromise: Promise<User> }) {
const user = use(userPromise)
return (
<div>
<h1>{user.name}</h1>
<p>{user.email}</p>
</div>
)
}
export function UserProfile({ userPromise }: { userPromise: Promise<User> }) {
return (
<Suspense fallback={<p>Loading profile…</p>}>
<ProfileContent userPromise={userPromise} />
</Suspense>
)
}Testing with a resolved promise:
import { render, screen } from '@testing-library/react'
import { describe, it, expect } from 'vitest'
import { UserProfile } from './UserProfile'
describe('UserProfile', () => {
it('renders user data after promise resolves', async () => {
const userPromise = Promise.resolve({
id: '1',
name: 'Alice Chen',
email: 'alice@example.com',
})
render(<UserProfile userPromise={userPromise} />)
// Initially shows loading state
expect(screen.getByText('Loading profile…')).toBeInTheDocument()
// Wait for the resolved data
expect(await screen.findByText('Alice Chen')).toBeInTheDocument()
expect(screen.getByText('alice@example.com')).toBeInTheDocument()
})
it('shows loading fallback while promise is pending', async () => {
// Never-resolving promise to test the loading state
const userPromise = new Promise<never>(() => {})
render(<UserProfile userPromise={userPromise} />)
expect(screen.getByText('Loading profile…')).toBeInTheDocument()
})
})Use findBy* queries (which wait up to 1000ms by default) when testing the resolved state. Avoid getBy* immediately after render — the Suspense boundary hasn't resolved yet.
Testing Error Boundaries with New Hooks
React 19's concurrent features interact with error boundaries differently. An error thrown inside use() propagates to the nearest error boundary. Testing this requires a proper error boundary component:
// ErrorBoundary.tsx
import { Component, type ReactNode } from 'react'
interface Props {
fallback: ReactNode
children: ReactNode
}
interface State {
hasError: boolean
error?: Error
}
export class ErrorBoundary extends Component<Props, State> {
state: State = { hasError: false }
static getDerivedStateFromError(error: Error): State {
return { hasError: true, error }
}
render() {
if (this.state.hasError) {
return this.props.fallback
}
return this.props.children
}
}import { render, screen } from '@testing-library/react'
import { describe, it, expect } from 'vitest'
import { Suspense } from 'react'
import { ErrorBoundary } from './ErrorBoundary'
import { UserProfile } from './UserProfile'
describe('UserProfile error handling', () => {
it('shows error boundary when promise rejects', async () => {
// Suppress expected React error boundary console output in test
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
const userPromise = Promise.reject(new Error('User not found'))
render(
<ErrorBoundary fallback={<p>Failed to load user</p>}>
<UserProfile userPromise={userPromise} />
</ErrorBoundary>
)
expect(await screen.findByText('Failed to load user')).toBeInTheDocument()
consoleSpy.mockRestore()
})
})Running Tests and Checking Coverage
# Run all tests
npx vitest run
<span class="hljs-comment"># Watch mode during development
npx vitest
<span class="hljs-comment"># Coverage report
npx vitest run --coverageFor CI, add to package.json:
{
"scripts": {
"test": "vitest run",
"test:watch": "vitest",
"test:coverage": "vitest run --coverage"
}
}What Automated Tests Miss
Unit tests with Vitest catch hook logic and component behaviour, but they don't catch:
- Network-level failures between your Server Actions and the client
- Race conditions in optimistic updates under slow connections
- Real browser rendering of concurrent React features
- User session state that affects
useOptimisticinitial values
For gaps like these, HelpMeTest runs Robot Framework tests against your live app on a schedule. When a Server Action silently fails and your optimistic UI lies to users, a monitored test catches it before your users do.
Summary
React 19's new hooks are testable with Vitest and React Testing Library, but they require specific patterns:
useOptimistic: use deferred promises to assert mid-flight state; wrap settlements inact()useFormStatus: always render inside a parent<form>with an async actionuse(): wrap in<Suspense>and usefindBy*queries for async resolution
The concurrent renderer handles most complexity for you — your job is to give tests the right async boundaries.