How to test async code in React

Testing asynchronous code in React components ensures data fetching, API calls, and delayed updates work correctly. As the creator of CoreUI with over 12 years of React experience since 2014, I’ve tested countless async operations in production applications. React Testing Library provides async utilities like waitFor, findBy queries, and proper handling of promises and timers. This approach creates reliable tests for components that fetch data, handle loading states, and manage async operations.

Use React Testing Library async utilities to test components with data fetching, API calls, and async state updates.

Component with data fetching:

// UserProfile.jsx
import { useState, useEffect } from 'react'

export default function UserProfile({ userId }) {
  const [user, setUser] = useState(null)
  const [loading, setLoading] = useState(true)
  const [error, setError] = useState(null)

  useEffect(() => {
    setLoading(true)
    fetch(`/api/users/${userId}`)
      .then(res => {
        if (!res.ok) throw new Error('Failed to fetch')
        return res.json()
      })
      .then(data => {
        setUser(data)
        setError(null)
      })
      .catch(err => {
        setError(err.message)
        setUser(null)
      })
      .finally(() => setLoading(false))
  }, [userId])

  if (loading) return <div>Loading...</div>
  if (error) return <div>Error: {error}</div>
  if (!user) return <div>No user found</div>

  return (
    <div>
      <h1>{user.name}</h1>
      <p>{user.email}</p>
    </div>
  )
}

Test with waitFor:

// UserProfile.test.jsx
import { render, screen, waitFor } from '@testing-library/react'
import UserProfile from './UserProfile'

global.fetch = jest.fn()

describe('UserProfile', () => {
  beforeEach(() => {
    fetch.mockClear()
  })

  it('shows loading state initially', () => {
    fetch.mockImplementation(() => new Promise(() => {}))

    render(<UserProfile userId={1} />)

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

  it('displays user data after loading', async () => {
    const mockUser = { name: 'John Doe', email: '[email protected]' }
    fetch.mockResolvedValueOnce({
      ok: true,
      json: async () => mockUser
    })

    render(<UserProfile userId={1} />)

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

    await waitFor(() => {
      expect(screen.getByText('John Doe')).toBeInTheDocument()
    })

    expect(screen.getByText('[email protected]')).toBeInTheDocument()
    expect(screen.queryByText('Loading...')).not.toBeInTheDocument()
  })

  it('handles fetch errors', async () => {
    fetch.mockRejectedValueOnce(new Error('Network error'))

    render(<UserProfile userId={1} />)

    await waitFor(() => {
      expect(screen.getByText('Error: Network error')).toBeInTheDocument()
    })
  })
})

Using findBy queries (recommended):

// findBy automatically waits and retries
it('displays user data with findBy', async () => {
  const mockUser = { name: 'John Doe', email: '[email protected]' }
  fetch.mockResolvedValueOnce({
    ok: true,
    json: async () => mockUser
  })

  render(<UserProfile userId={1} />)

  // findBy waits automatically, no waitFor needed
  expect(await screen.findByText('John Doe')).toBeInTheDocument()
  expect(await screen.findByText('[email protected]')).toBeInTheDocument()
})

it('handles errors with findBy', async () => {
  fetch.mockRejectedValueOnce(new Error('Failed'))

  render(<UserProfile userId={1} />)

  expect(await screen.findByText(/Error: Failed/i)).toBeInTheDocument()
})

Testing user-triggered async operations:

// SearchComponent.jsx
import { useState } from 'react'

export default function SearchComponent() {
  const [query, setQuery] = useState('')
  const [results, setResults] = useState([])
  const [searching, setSearching] = useState(false)

  const handleSearch = async () => {
    setSearching(true)
    try {
      const res = await fetch(`/api/search?q=${query}`)
      const data = await res.json()
      setResults(data)
    } catch (error) {
      setResults([])
    } finally {
      setSearching(false)
    }
  }

  return (
    <div>
      <input
        value={query}
        onChange={(e) => setQuery(e.target.value)}
        placeholder='Search...'
      />
      <button onClick={handleSearch} disabled={searching}>
        {searching ? 'Searching...' : 'Search'}
      </button>
      <ul>
        {results.map(item => (
          <li key={item.id}>{item.name}</li>
        ))}
      </ul>
    </div>
  )
}

// SearchComponent.test.jsx
import { render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import SearchComponent from './SearchComponent'

describe('SearchComponent', () => {
  it('performs search on button click', async () => {
    const user = userEvent.setup()
    const mockResults = [
      { id: 1, name: 'Result 1' },
      { id: 2, name: 'Result 2' }
    ]

    fetch.mockResolvedValueOnce({
      json: async () => mockResults
    })

    render(<SearchComponent />)

    await user.type(screen.getByPlaceholderText('Search...'), 'test query')
    await user.click(screen.getByRole('button', { name: /search/i }))

    expect(await screen.findByText('Result 1')).toBeInTheDocument()
    expect(await screen.findByText('Result 2')).toBeInTheDocument()
  })
})

Testing with timers:

// DelayedMessage.jsx
import { useState, useEffect } from 'react'

export default function DelayedMessage() {
  const [message, setMessage] = useState('')

  useEffect(() => {
    const timer = setTimeout(() => {
      setMessage('Message appeared after delay')
    }, 1000)

    return () => clearTimeout(timer)
  }, [])

  return <div>{message || 'Waiting...'}</div>
}

// DelayedMessage.test.jsx
import { render, screen, waitFor } from '@testing-library/react'
import DelayedMessage from './DelayedMessage'

jest.useFakeTimers()

describe('DelayedMessage', () => {
  it('shows message after delay with fake timers', async () => {
    render(<DelayedMessage />)

    expect(screen.getByText('Waiting...')).toBeInTheDocument()

    jest.advanceTimersByTime(1000)

    await waitFor(() => {
      expect(screen.getByText('Message appeared after delay')).toBeInTheDocument()
    })
  })
})

Testing multiple async operations:

it('handles multiple sequential API calls', async () => {
  const mockUser = { id: 1, name: 'John' }
  const mockPosts = [{ id: 1, title: 'Post 1' }]

  fetch
    .mockResolvedValueOnce({ json: async () => mockUser })
    .mockResolvedValueOnce({ json: async () => mockPosts })

  render(<UserWithPosts userId={1} />)

  expect(await screen.findByText('John')).toBeInTheDocument()
  expect(await screen.findByText('Post 1')).toBeInTheDocument()

  expect(fetch).toHaveBeenCalledTimes(2)
})

Testing error boundaries with async:

it('catches async errors in error boundary', async () => {
  const spy = jest.spyOn(console, 'error').mockImplementation(() => {})

  fetch.mockRejectedValueOnce(new Error('API Error'))

  render(
    <ErrorBoundary>
      <UserProfile userId={1} />
    </ErrorBoundary>
  )

  expect(await screen.findByText(/Something went wrong/i)).toBeInTheDocument()

  spy.mockRestore()
})

Using waitForElementToBeRemoved:

import { waitForElementToBeRemoved } from '@testing-library/react'

it('waits for loading spinner to disappear', async () => {
  fetch.mockResolvedValueOnce({
    ok: true,
    json: async () => ({ name: 'John' })
  })

  render(<UserProfile userId={1} />)

  const loading = screen.getByText('Loading...')

  await waitForElementToBeRemoved(loading)

  expect(screen.getByText('John')).toBeInTheDocument()
})

Best Practice Note

Use findBy queries instead of waitFor + getBy—they’re simpler and clearer. Mock fetch using Jest to control API responses in tests. Use waitFor when you need custom conditions or multiple assertions. Use waitForElementToBeRemoved when testing loading states. Set appropriate timeouts for slow operations. Use jest.useFakeTimers() for timer-based async code. Always clean up mocks in beforeEach or afterEach. This is how we test async operations in CoreUI React components—ensuring reliable tests for data fetching, loading states, and error handling in production applications.


Speed up your responsive apps and websites with fully-featured, ready-to-use open-source admin panel templates—free to use and built for efficiency.


About the Author

Subscribe to our newsletter
Get early information about new products, product updates and blog posts.
How to Open All Links in New Tab Using JavaScript
How to Open All Links in New Tab Using JavaScript

How to Center a Button in CSS
How to Center a Button in CSS

JavaScript Template Literals: Complete Developer Guide
JavaScript Template Literals: Complete Developer Guide

How to capitalize the first letter in JavaScript?
How to capitalize the first letter in JavaScript?

Answers by CoreUI Core Team