React 85 ⚛️ Testing User Interactions and Events
A component test that renders markup and asserts on the output is only half a test. The other half is the interaction: the user types into a field, clicks a button, presses Escape, tabs through a form, or holds a modifier key while clicking. React Testing Library’s userEvent library exists to simulate those interactions with the fidelity of a real browser, firing the full sequence of events that a human action produces rather than a single synthetic event . The distinction between userEvent and fireEvent is the central decision in this chapter, and it is not a matter of preference. fireEvent dispatches one event; userEvent dispatches the sequence—focus, keydown, keypress, input, keyup, change—that the browser would dispatch for a real interaction .
The API changed significantly in version 14. The old synchronous userEvent.click(element) calls were replaced by an asynchronous API that requires userEvent.setup() before rendering and await on every interaction . This is the version that is current, and tests written against the old synchronous API will fail silently or produce confusing results. The setup call creates a “user session” that tracks the pointer, the keyboard, and the clipboard across multiple interactions, which is what allows a test to simulate a user who holds Shift while clicking, or types with a delay between keystrokes .
Key point: Use userEvent.setup() before render, and await every interaction. userEvent fires the full event sequence and checks visibility and interactability; fireEvent fires a single event and is only appropriate for events no human dispatches, such as resize, scroll, or a synthetic payload with exact properties. The test must be async, and every user.click, user.type, user.keyboard, and user.tab must be awaited.
Why userEvent replaced fireEvent
The single-event problem. fireEvent.click(button) dispatches a click event and nothing else. A real click involves the pointer moving to the element, the element receiving focus, the mouse button pressing down, the button releasing, and the click firing. Each of those steps fires its own event, and components that listen for mousedown, focus, or keyup will not see them in a fireEvent test . The test passes while the component is broken for real users, because the component’s event handlers for the intermediate events are never exercised.
The interactability problem. userEvent checks whether the element is visible and enabled before interacting with it. If the button is hidden behind a display: none or has the disabled attribute, userEvent.click throws an error. fireEvent.click dispatches the event regardless, and the test passes even though a real user could not have clicked the button . This check is a free accessibility assertion: a component that fails it is a component that real users cannot interact with.
The keyboard problem. Simulating a keyboard shortcut with fireEvent requires manually constructing a KeyboardEvent with the correct key, code, and modifier flags. Simulating the same shortcut with userEvent.keyboard uses a compact syntax: {Control>}s{/Control} holds Control, presses S, and releases Control . The syntax supports held modifiers, key codes, and special keys, and the library translates them into the correct sequence of events.
The session problem. A userEvent session tracks state across interactions. A test can press and hold Shift, click an element, and release Shift, and the click will carry the shiftKey: true flag . This is impossible with fireEvent without manually passing the flag on every event. The session is what makes the library a simulation of a user rather than a dispatcher of events.
a. The setup and the async API
The userEvent.setup() call creates a user session and must run before the component is rendered. The test function must be async, and every interaction must be awaited.
// Import render and screen from RTL
import { render, screen } from '@testing-library/react'
// Import the userEvent library
import userEvent from '@testing-library/user-event'
test('increments the counter on click', async () => {
// Create the user session before rendering
const user = userEvent.setup()
// Render the component
render(<Counter />)
// Find the button by its accessible role and name
const button = screen.getByRole('button', { name: /increment/i })
// Click the button and await the interaction
// The click fires the full sequence: pointer, focus, mousedown, mouseup, click
await user.click(button)
// Assert on the visible output, not on internal state
expect(screen.getByText('1')).toBeInTheDocument()
})
The await is not optional. user.click returns a Promise that resolves when the interaction sequence is complete. A test that calls user.click(button) without await will run its assertion before the click handler has fired, and the assertion will fail with a stale DOM . The failure is confusing because the click “happened”—it just has not finished yet.
The userEvent.setup() call can be configured. The delay option controls the time between events (default 0, which means no delay). The advanceTimers option integrates with Jest’s fake timers when the component uses setTimeout or setInterval. The pointerEventsCheck option controls how strictly the library checks the pointer-events CSS property . The defaults are correct for most tests; the configuration is for special cases.
b. The interaction methods
userEvent provides a method for each interaction type. The most common are click, type, clear, keyboard, tab, and selectOptions.
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
test('fills and submits a form', async () => {
const user = userEvent.setup()
const onSubmit = jest.fn()
render(<LoginForm onSubmit={onSubmit} />)
// Type into the email field
// type focuses the field, then fires keydown, keypress, input, keyup for each character
await user.type(screen.getByLabelText(/email/i), 'alice@example.com')
// Type into the password field
await user.type(screen.getByLabelText(/password/i), 'secret123')
// Click the submit button
await user.click(screen.getByRole('button', { name: /sign in/i }))
// Assert on the handler's arguments
expect(onSubmit).toHaveBeenCalledWith({
email: 'alice@example.com',
password: 'secret123',
})
})
The user.clear method clears the field by selecting all and pressing Delete. It is the correct way to replace an existing value, because typing over a field that already has content appends rather than replaces . The user.tripleClick selects an entire paragraph, and typing after it replaces the selection.
The user.keyboard method accepts a string with special syntax. A plain character types that character. A key name in braces presses that key: {Enter}, {Escape}, {Tab}, {ArrowDown}. A modifier followed by > holds the modifier, and {/Modifier} releases it. The syntax {Shift>}{Tab}{/Shift} holds Shift, presses Tab, and releases Shift—which is how Shift+Tab is simulated . The user.tab() method is a shortcut for a plain Tab press; user.tab({ shift: true }) is the shortcut for Shift+Tab .
The user.selectOptions method selects options in a <select>. It accepts the option elements, their values, or their visible text. The user.deselectOptions method is the reverse for multi-select elements .
c. When fireEvent is still appropriate
fireEvent is not obsolete. It is the correct tool for events that no human dispatches. A resize event on the window, a scroll event on a container, or a custom event that a third-party library emits are all fired by the browser or the library, not by the user. userEvent has no method for them because there is no user action to simulate .
fireEvent is also the correct tool when the test needs to set exact event properties that the user simulation would not produce. A change event with a specific target.value that does not correspond to actual typing is a fireEvent case. The userEvent alternative would require typing the value character by character, which is slower and does not test the same thing.
import { render, screen, fireEvent } from '@testing-library/react'
test('handles a window resize', () => {
// Render the component that listens for resize
render(<ResponsiveLayout />)
// fireEvent is correct here because no user "does" a resize
// The browser fires it, and the test simulates the browser
fireEvent(window, new Event('resize'))
// Assert that the layout responded
expect(screen.getByTestId('layout')).toHaveClass('mobile')
})
The performance consideration is real. userEvent.type is slower than fireEvent.change because it fires an event for every character. For a test that types a long string into a field that only cares about the final value, fireEvent.change is faster and sufficient. The testing-library/user-event issue tracker acknowledges this trade-off and suggests delay: null in the setup options when the typing speed matters . The default guidance remains userEvent for interactions that resemble real user behavior; fireEvent is the escape hatch for the cases that do not.
Complete Example Session
The following session builds a test for a search component with a debounced input, a keyboard shortcut, and a list of results, using userEvent for the interactions and fireEvent for the window event.
// ============================================
// PART 1: THE COMPONENT
// ============================================
import { useState, useEffect, useRef } from 'react'
export function SearchBox({ onSearch }) {
const [query, setQuery] = useState('')
const [results, setResults] = useState([])
const inputRef = useRef(null)
// Debounce the search: wait 300ms after the last keystroke
useEffect(() => {
if (!query) { setResults([]); return }
const timer = setTimeout(() => {
onSearch(query).then(setResults)
}, 300)
return () => clearTimeout(timer)
}, [query, onSearch])
// Focus the input on Ctrl+K
useEffect(() => {
function onKeyDown(e) {
if (e.ctrlKey && e.key === 'k') {
e.preventDefault()
inputRef.current?.focus()
}
}
window.addEventListener('keydown', onKeyDown)
return () => window.removeEventListener('keydown', onKeyDown)
}, [])
return (
<div>
<label htmlFor="search">Search</label>
<input
id="search"
ref={inputRef}
value={query}
onChange={(e) => setQuery(e.target.value)}
/>
<ul>
{results.map((r) => <li key={r}>{r}</li>)}
</ul>
</div>
)
}
// ============================================
// PART 2: THE TEST
// ============================================
import { render, screen, fireEvent } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { SearchBox } from './SearchBox'
test('searches after typing and focuses on Ctrl+K', async () => {
// Create the user session before rendering
const user = userEvent.setup()
// A mock search function that resolves with results
const onSearch = jest.fn().mockResolvedValue(['Result 1', 'Result 2'])
render(<SearchBox onSearch={onSearch} />)
// Type into the search field
// userEvent fires the full key sequence for each character
await user.type(screen.getByLabelText(/search/i), 'react')
// The debounce means the search has not fired yet
expect(onSearch).not.toHaveBeenCalled()
// Wait for the debounced search to resolve
// findByText retries until the element appears
expect(await screen.findByText('Result 1')).toBeInTheDocument()
expect(onSearch).toHaveBeenCalledWith('react')
// Press Ctrl+K to focus the input
// The keyboard syntax holds Control, presses K, and releases Control
await user.keyboard('{Control>}k{/Control}')
// Assert that the input has focus
expect(screen.getByLabelText(/search/i)).toHaveFocus()
// fireEvent is correct for the window resize: no user action produces it
fireEvent(window, new Event('resize'))
})
The two parts cover the component with its debounce and keyboard shortcut, and the test that exercises both with userEvent and fireEvent. What the session demonstrates is that the interaction is awaited, the debounce is handled by findByText, the keyboard shortcut uses the held-modifier syntax, and the window event uses fireEvent because it has no user equivalent.
Quick Reference
userEvent vs fireEvent
| Aspect | userEvent | fireEvent |
|---|---|---|
| Events fired | Full sequence | Single event |
| Visibility check | Yes | No |
| Async | Yes (returns Promise) | No |
| Setup required | userEvent.setup() | None |
| Best for | Real user actions | Window events, exact payloads |
Common userEvent Methods
| Method | Purpose |
|---|---|
user.click(el) | Click with full sequence |
user.type(el, text) | Type character by character |
user.clear(el) | Clear the field |
user.keyboard(keys) | Press keys with modifiers |
user.tab() | Move focus forward |
user.tab({ shift: true }) | Move focus backward |
user.selectOptions(el, opts) | Select in a <select> |
Keyboard Syntax
| Syntax | Meaning |
|---|---|
{Enter} | Press Enter |
{Shift>} | Hold Shift |
{/Shift} | Release Shift |
{Control>}k{/Control} | Ctrl+K |
{Shift>}{Tab}{/Shift} | Shift+Tab |
Async Patterns
| Pattern | Use |
|---|---|
await user.click(...) | Every interaction |
await screen.findByText(...) | After interaction that triggers async render |
fireEvent(window, new Event(...)) | Non-user events |
Best Practices
✅ Do This:
// Setup before render
const user = userEvent.setup()
render(<Component />)
// Await every interaction
await user.click(button)
// Use keyboard syntax for modifiers
await user.keyboard('{Control>}s{/Control}')
// Use findBy for async elements
expect(await screen.findByText(/loaded/i)).toBeInTheDocument()
// Use fireEvent for non-user events
fireEvent(window, new Event('resize'))
❌ Don’t Do This:
// Forget to await userEvent
user.click(button) // assertion runs too early
// Use fireEvent for user actions
fireEvent.click(button) // misses focus, mousedown, mouseup
// Use a single event for typing
fireEvent.change(input, { target: { value: 'x' } }) // no key sequence
// Forget userEvent.setup()
userEvent.click(button) // undefined user
Common Pitfalls
| Pitfall | Why It Happens | Fix |
|---|---|---|
| Assertion runs before interaction | No await on user.* | Add await |
| Test is not async | Forgot async on the test function | Add async |
| Shift+Tab does not work | Used {Shift}{Tab} without holding | {Shift>}{Tab}{/Shift} or user.tab({ shift: true }) |
| Element not visible error | userEvent checks visibility | Fix the CSS or use fireEvent intentionally |
| Type appends instead of replaces | Did not clear first | await user.clear(input) before typing |
| Timeout on long typing | Default delay between keys | userEvent.setup({ delay: null }) |
Real-World Examples
1. Setup and click
const user = userEvent.setup()
render(<Button />)
await user.click(screen.getByRole('button'))
2. Type into a field
await user.type(screen.getByLabelText(/email/i), 'a@b.com')
3. Clear before retyping
await user.clear(input)
await user.type(input, 'new')
4. Keyboard shortcut
await user.keyboard('{Control>}s{/Control}')
5. Shift+Tab
await user.tab({ shift: true })
6. Select options
await user.selectOptions(select, ['Apple', 'Banana'])
7. Triple click to select
await user.tripleClick(textbox)
8. Window resize with fireEvent
fireEvent(window, new Event('resize'))
9. Exact change event with fireEvent
fireEvent.change(input, { target: { value: 'exact' } })
10. Find async result
expect(await screen.findByText('Result')).toBeInTheDocument()
Visual
fireEvent vs userEvent
┌──────────────────────────────────────────────┐
│ fireEvent.click(button) │
│ → dispatches one click event │
│ │
│ await user.click(button) │
│ → pointer move, focus, mousedown, │
│ mouseup, click │
│ → checks visibility and disabled state │
└──────────────────────────────────────────────┘
Keyboard Modifiers
┌──────────────────────────────────────────────┐
│ {Control>}s{/Control} │
│ │ │
│ ▼ │
│ keydown Control │
│ keydown s │
│ keyup s │
│ keyup Control │
│ │
│ {Shift>}{Tab}{/Shift} │
│ → Shift+Tab │
└──────────────────────────────────────────────┘
Async Interaction
┌──────────────────────────────────────────────┐
│ await user.type(input, 'react') │
│ │ │
│ ▼ │
│ debounce 300ms │
│ │ │
│ ▼ │
│ await screen.findByText('Result 1') │
│ → retries until the element appears │
└──────────────────────────────────────────────┘
When to Use fireEvent
┌──────────────────────────────────────────────┐
│ USER ACTION → userEvent │
│ click, type, keyboard, tab, select │
│ │
│ NON-USER EVENT → fireEvent │
│ resize, scroll, custom event, │
│ exact payload with specific properties │
└──────────────────────────────────────────────┘
Summary
| Item | Value |
|---|---|
| Interaction library | @testing-library/user-event |
| Setup | userEvent.setup() before render |
| Async | Every interaction must be awaited |
| Click | user.click(el) |
| Type | user.type(el, text) |
| Clear | user.clear(el) |
| Keyboard | user.keyboard('{Control>}s{/Control}') |
| Tab | user.tab() or user.tab({ shift: true }) |
| fireEvent | For non-user events only |
Key takeaways:
userEventsimulates a user;fireEventdispatches an event. The difference is the full event sequence and the interactability checks. AfireEventtest can pass while the component is broken for real users .- The API is asynchronous in v14.
userEvent.setup()is required, the test must beasync, and every interaction must be awaited. A missingawaitproduces a stale DOM assertion . - The keyboard syntax handles modifiers.
{Control>}s{/Control}holds Control, presses S, and releases.{Shift>}{Tab}{/Shift}is Shift+Tab.user.tab({ shift: true })is the shortcut . user.clearis the correct way to replace a value. Typing over an existing field appends; clearing first replaces .fireEventis not obsolete. It is the correct tool for events no human dispatches—resize,scroll, custom events—and for tests that need exact event properties .findByhandles the async result of an interaction. Afterawait user.type, the debounced search resolves later, andawait screen.findByTextretries until it appears.
Remember: The interaction is the part of the test that proves the component works for a real person. userEvent is the library that makes that proof credible, because it fires the events a browser fires and checks the conditions a browser checks. The setup call creates the session, the await waits for the sequence, and the keyboard syntax expresses the modifiers. fireEvent remains for the events that have no user, and the distinction is not a matter of style—it is a matter of what the test actually verifies. A test that clicks with fireEvent verifies that the click handler works in isolation; a test that clicks with userEvent verifies that the button is visible, enabled, focused, and clickable, and that the handler works in the context of a real interaction.
Stop using slow, ad-bloated tool sites! 🤮
🔎 Search “KandZ Tools” on Google to use many professional utilities for free.
KandZ.me is the ultimate minimalist hub for:
✅ Finance (Mortgage, Interest, Inflation)
✅ Tech (Base64, JSON, Dev Suite, IP)
✅ Health (BMI, BMR, TDEE)
✅ Productivity (Timer, Workspace, QR)
⚡️ Fast & Private
🔒 No data leaves your device
💎 100% Free
🔗 Use it now: https://tools.kandz.me
🔖 Bookmark it—you’ll need it later!