React 19 Testing Guide: useOptimistic, useFormStatus, and use() with Vitest

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

vitest.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 --coverage

For 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 useOptimistic initial 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 in act()
  • useFormStatus: always render inside a parent <form> with an async action
  • use(): wrap in <Suspense> and use findBy* queries for async resolution

The concurrent renderer handles most complexity for you — your job is to give tests the right async boundaries.

Read more