In a React Native Testing Library test, why can pressing a button inside a waitFor callback call the mocked API more than once, and how do you restructure it?
answer
- the callback is re-run, not resumed
- every retry repeats the press
- rejected async callback means try again
- act outside, assert inside
- no-wait-for-side-effects lint rule
basics
~20 swaitFor re-runs its whole callback every 50 ms until it stops throwing, so a press inside it is repeated on each failed check. Press once before waitFor and keep only the assertion inside, or await a findBy query instead.
solid answer
~40 s`waitFor` retries by calling the callback again from the top, not by resuming at the failed line. In the catalog test, `await waitFor(async () => { await user.press(reserve); expect(screen.getByText('Reserved')).toBeOnTheScreen(); })` presses Reserve, finds the label not rendered yet because the mocked `reserveBook` promise has not resolved, rejects, and on the next interval presses again. Each retry is another call to the API mock, so a later `toHaveBeenCalledTimes(1)` fails with 2 or 3, and the count changes between runs. The fix is to perform the interaction exactly once, outside, then wait only on the consequence: `await user.press(reserve); expect(await screen.findByText('Reserved')).toBeOnTheScreen();`. Keep `waitFor` callbacks free of side effects, which the `no-wait-for-side-effects` lint rule partly enforces.
code
typescript · 14 linesimport { render, screen, userEvent } from '@testing-library/react-native';
test('reserves a book once', async () => {
mockReserveBook.mockResolvedValue({ status: 'reserved' });
const user = userEvent.setup();
await render(<BookDetails bookId="b1" />);
// interaction happens exactly once, outside any wait
await user.press(screen.getByRole('button', { name: 'Reserve Dune' }));
// only the consequence is awaited
expect(await screen.findByText('Reserved')).toBeOnTheScreen();
expect(mockReserveBook).toHaveBeenCalledTimes(1);
});go deeper
Remember that waitFor calls its callback many times, so the callback should only contain queries and assertions, never a press or a typed value.
Explain the retry loop: throw or rejection means call again from the top, any returned value means done, and why that repeats a press and the API call it triggers.
Diagnose a flaky call-count failure as a side effect in a retry loop, restructure it to act once and wait on the consequence, and enforce it with no-wait-for-side-effects.
Treat this as a suite-level hygiene rule: codify act-then-wait in test helpers and lint so that async flakiness from repeated interactions cannot be introduced.
## What waitFor actually does with its callback `waitFor` in **React Native Testing Library (RNTL)** is a polling loop around a function you give it. It calls the callback immediately; if the call throws, or returns a promise that rejects, it records the error and **calls the whole callback again** on a later tick of its interval (50 ms by default). It stops when a call succeeds or when the timeout (1000 ms by default) expires. Nothing is resumed and nothing is remembered between calls except the last error. That is harmless when the callback only **reads**: a query plus an assertion can run twenty times without changing anything. It is a bug when the callback **does** something. ## The catalog failure ```tsx await waitFor(async () => { await user.press(screen.getByRole('button', { name: 'Reserve Dune' })); expect(screen.getByText('Reserved')).toBeOnTheScreen(); }); expect(mockReserveBook).toHaveBeenCalledTimes(1); ``` The Reserve button's `onPress` calls the mocked `reserveBook('b1')`, which returns a promise; the screen shows "Reserved" once it resolves. The sequence is: 1. First call: the press fires `onPress`, `reserveBook` is called once, and the assertion runs before the promise has resolved, so `getByText('Reserved')` throws and the async callback rejects. 2. Next interval: the callback runs again from the top and presses again, a second `reserveBook` call. 3. Eventually "Reserved" is rendered in time and the callback succeeds. 4. `toHaveBeenCalledTimes(1)` fails with 2 or 3, and the number differs between machines and runs. The test is now **flaky and misleading**: the component is correct, and the failure blames the API call count. ## Why the count changes between runs The number of repeated presses is decided by timing, not by the code. Under real timers each `user.press` itself takes at least 130 ms, because it reproduces React Native's minimum press duration, and while the async callback's promise is pending RNTL skips further checks. How many rejected attempts happen before the mocked `reserveBook` promise resolves and the label renders depends on machine load, the mock's resolution and the 50 ms interval. That is why the same test reports 2 calls locally and 3 on a slower CI runner, and why rerunning it "fixes" it. A test whose failure mode varies with CPU speed is the signature of work inside a retry loop. ## Other forms of the same mistake - `await fireEvent.press(button)` inside `waitFor`; in RNTL 14 `fireEvent` is async, but that does not stop it being repeated. - `await user.type(input, 'dune')` inside `waitFor`, which types the text again on every retry. - Calling a mocked API, navigating, or mutating a store inside the callback. - `rerender(...)` inside the callback, which remounts or updates the tree on every poll. ## The restructure | Step | Where it goes | |---|---| | The interaction (press, type, scroll) | Before `waitFor`, awaited, exactly once | | Waiting for an element to appear | `await screen.findBy*(...)` | | Waiting for a non-element condition | `await waitFor(() => expect(...))` with no side effects | | Follow-up assertions | Plain synchronous assertions after the wait | ```tsx await user.press(screen.getByRole('button', { name: 'Reserve Dune' })); expect(await screen.findByText('Reserved')).toBeOnTheScreen(); expect(mockReserveBook).toHaveBeenCalledTimes(1); ``` ## Related waitFor contract points - **The callback must throw to mean "not yet".** `await waitFor(() => screen.queryByText('Reserved') !== null)` resolves on the first call, because `false` is a return value, not a failure. - **An empty callback waits for nothing.** `await waitFor(() => {})` succeeds immediately. - **Async callbacks are handled.** While the returned promise is pending, RNTL skips further checks; a rejection is recorded and the next interval calls the callback again, which is exactly what repeats the press above. - **One assertion per callback.** Once the first async condition holds, the rest normally need no waiting, and separate calls give a precise failure. ## Spotting it in review - Any `await` inside a `waitFor` callback other than an awaited read deserves a second look. - A `waitFor` whose callback starts with an interaction and ends with an assertion is almost always this bug. - A mock-count assertion that fails with a number that varies between runs points at repeated side effects. ## Tooling eslint-plugin-testing-library's `no-wait-for-side-effects` rule flags some of these patterns, such as event calls inside the callback. It cannot recognise every side effect, for example a call to your own helper that presses a button, so the rule backs up the habit rather than replacing it: **act first, then wait on the consequence.**
- The waitFor callback is async and awaits user.press. Why does RNTL call it again rather than failing straight away?RNTL treats a callback that returns a promise as a pending check. When that promise rejects, it records the rejection as the latest error and calls the callback again on the next interval, just as it would after a synchronous throw. Only the timeout turns the last error into a failure, so every rejected attempt re-runs the press.
- Is it ever acceptable to put anything other than assertions inside a waitFor callback?Read-only work is fine: queries, reading a mock's calls, computing a value to return. What must stay out is anything that changes state or has an external effect, because it runs once per retry and the retry count depends on timing.
saying these in an interview costs you the question
- waitFor resumes from the line that failed instead of re-running the callback.
- Putting the press inside waitFor makes the test more robust against a missed tap.
- Awaiting fireEvent in RNTL 14 means it is safe inside waitFor.
- Returning false from the waitFor callback makes it keep retrying.
- The lint rule catches every side effect, so code review need not look.