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.



