| |

React 85 ⚛️ Testing User Interactions and Events

⏱️ Estimated read: 14 min | |

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

AspectuserEventfireEvent
Events firedFull sequenceSingle event
Visibility checkYesNo
AsyncYes (returns Promise)No
Setup requireduserEvent.setup()None
Best forReal user actionsWindow events, exact payloads

Common userEvent Methods

MethodPurpose
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

SyntaxMeaning
{Enter}Press Enter
{Shift>}Hold Shift
{/Shift}Release Shift
{Control>}k{/Control}Ctrl+K
{Shift>}{Tab}{/Shift}Shift+Tab

Async Patterns

PatternUse
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

PitfallWhy It HappensFix
Assertion runs before interactionNo await on user.*Add await
Test is not asyncForgot async on the test functionAdd async
Shift+Tab does not workUsed {Shift}{Tab} without holding{Shift>}{Tab}{/Shift} or user.tab({ shift: true })
Element not visible erroruserEvent checks visibilityFix the CSS or use fireEvent intentionally
Type appends instead of replacesDid not clear firstawait user.clear(input) before typing
Timeout on long typingDefault delay between keysuserEvent.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

ItemValue
Interaction library@testing-library/user-event
SetupuserEvent.setup() before render
AsyncEvery interaction must be awaited
Clickuser.click(el)
Typeuser.type(el, text)
Clearuser.clear(el)
Keyboarduser.keyboard('{Control>}s{/Control}')
Tabuser.tab() or user.tab({ shift: true })
fireEventFor non-user events only

Key takeaways:

  • userEvent simulates a user; fireEvent dispatches an event. The difference is the full event sequence and the interactability checks. A fireEvent test can pass while the component is broken for real users .
  • The API is asynchronous in v14. userEvent.setup() is required, the test must be async, and every interaction must be awaited. A missing await produces 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.clear is the correct way to replace a value. Typing over an existing field appends; clearing first replaces .
  • fireEvent is not obsolete. It is the correct tool for events no human dispatches—resize, scroll, custom events—and for tests that need exact event properties .
  • findBy handles the async result of an interaction. After await user.type, the debounced search resolves later, and await screen.findByText retries 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!