Why does Testing Library's user-event require a userEvent.setup() call and return a promise from every interaction, instead of exposing plain synchronous helpers like fireEvent does?
answer
- a sequence, not a single event
- devices have state between actions
- held Shift must survive the next click
- one instance per test, before render
basics
~20 sBecause one interaction is many ordered events with optional delays between them, which needs an asynchronous API, and because interactions share state — held modifier keys, pressed mouse buttons, the clipboard. setup() creates the instance that carries that state and your configuration.
solid answer
~40 sThe API shape follows from the abstraction. `fireEvent` is one `dispatchEvent`, so it can be synchronous. A user-event interaction is a sequence — pointer down, mouse down, focus, pointer up, click — that may space events out with a configurable delay and must let the framework process updates between them, so it is asynchronous and every call is awaited. `userEvent.setup()` returns an instance because interactions are not independent: it tracks which modifier keys and mouse buttons are currently held, so `keyboard('{Shift>}')` followed by a click produces a shift-clicked element, and it installs a clipboard stub so copy and paste work in jsdom. Setup also applies per-test options such as `delay`, `document` and `pointerEventsCheck`. In practice I call it once at the top of each test, before rendering, and use the returned `user` for everything.
code
javascript · 11 linesimport userEvent from '@testing-library/user-event'
const input = document.createElement('input')
document.body.append(input)
input.addEventListener('click', (event) => console.log('shiftKey:', event.shiftKey))
const user = userEvent.setup()
await user.keyboard('{Shift>}') // press and hold Shift
await user.click(input) // logs 'shiftKey: true'
await user.keyboard('{/Shift}') // release Shift
await user.click(input) // logs 'shiftKey: false'go deeper
Know the pattern: create the instance with userEvent.setup() at the top of the test, before rendering, and await every interaction you perform with it.
Explain why the shape exists — a sequence with delays needs to be asynchronous, and device state such as held keys and the clipboard needs an object that survives between calls.
Talk about isolation and configuration: one instance per test to stop device state leaking, and deliberate use of options rather than a globally relaxed configuration.
Own the ergonomics. Decide whether a project wraps setup and render in a shared test utility, what default options it encodes, and how you keep those defaults from silently weakening fidelity across the suite.
## The API shape is a consequence, not a style choice Candidates often describe `userEvent.setup()` as boilerplate. It is not — both halves of the API shape fall directly out of what an interaction is. **Asynchrony.** A single dispatched event is atomic; there is nothing to wait for. An interaction is a sequence of events, and user-event may insert a delay between them (typing with a per-character delay is the obvious case). Once a delay exists at all, the API must be promise-returning. There is a second benefit: yielding between the events of a sequence lets the framework process the updates each event caused, so the DOM the next event lands on is the DOM a real user would have been looking at. A synchronous version would have to fire the whole burst into an unchanging tree. **Instance state.** Real input devices have state. A keyboard has keys currently held down; a mouse has buttons currently pressed and a position. That state spans interactions: pressing and holding Shift, then clicking, is a shift-click. Modelling that requires an object that persists across calls, and that object is what `setup()` returns. ```javascript const user = userEvent.setup() await user.keyboard('{Shift>}') // press and hold Shift await user.click(lastItem) // this click carries shiftKey: true await user.keyboard('{/Shift}') // release ``` The `{Key>}` / `{/Key}` syntax means "press and hold" and "release", and it only means anything because the instance remembers between calls. ## What else setup() does - **Applies options for the test.** `delay` controls the pause between events in a sequence; `document` points the instance at a different document; `pointerEventsCheck` controls how aggressively the library refuses to interact with unreachable elements; `skipHover` and friends trim parts of a sequence you do not want. - **Stubs the clipboard.** jsdom has no working `navigator.clipboard`, so copy, cut and paste interactions would be untestable. Setup installs a stub for the life of the test so `user.copy()` and `user.paste()` behave, and restores the original afterwards. ## What happens if you skip it The direct API still exists — `userEvent.click(el)` works — but it creates a fresh instance internally for that one call. Two consequences follow: options you might want cannot be supplied, and no state survives between calls, so the held-Shift example above silently degrades into an ordinary click. Nothing errors; the test just quietly stops testing what you meant. That is why the recommended pattern is one `setup()` per test. ## Where to put the call Call it at the start of the test, **before** rendering: ```javascript const user = userEvent.setup() render(<Editor />) await user.click(screen.getByRole('button', { name: 'Bold' })) ``` Order matters because setup performs document-level work such as installing the clipboard stub, and because a shared instance created once outside all tests would leak device state — a key left held by one test would still be held in the next. One instance per test keeps tests independent, which is the same isolation discipline that applies to any other per-test fixture. A common convenience is to fold setup into a small helper that renders the component and returns both the instance and the render result, so every test in the file gets the same configuration without repeating it: ```javascript function renderWithUser(ui) { return { user: userEvent.setup(), ...render(ui) } } ``` ## The interview framing If you are asked this, the strong answer connects the shape to the semantics in one sentence — *an interaction is a stateful sequence, so the API is instance-based and asynchronous* — and then gives one concrete thing the state buys you, such as shift-clicking or clipboard support. The weak answer is "it is just how v14 works", which tells the interviewer you copied the pattern from a template without asking what it protects.
- What breaks if you create one shared user-event instance outside the tests and reuse it everywhere?Device state leaks between tests. A modifier key or mouse button left held at the end of one test is still held at the start of the next, producing shift-clicks nobody asked for and failures that depend on test order. Create one instance per test, for the same isolation reason you rebuild any other fixture.
- Does calling userEvent.click(el) directly, without setup, still work?Yes — it constructs an instance for that single call. You lose the ability to pass options and, more subtly, you lose continuity: nothing is remembered between calls, so held keys quietly evaporate. The test does not error, it just stops asserting what you intended, which is the more dangerous failure.
- Why does setup() need to touch the clipboard at all?jsdom ships no working `navigator.clipboard`, so copy, cut and paste interactions would have nothing to act on. Setup installs a stub for the duration of the test and restores the original afterwards, which is what makes `user.copy()` and `user.paste()` meaningful in a non-browser environment.
saying these in an interview costs you the question
- Calls setup() boilerplate with no purpose behind it
- Thinks the async API exists only to satisfy lint rules
- Creates one shared instance for the whole test file
- Believes setup() renders or queries anything
- Assumes held modifier keys persist without an instance