Waiting Strategies for Dynamic React Components
React rarely hands you a stable DOM. Fiber reconciliation detaches and re-attaches nodes during state transitions, Suspense swaps a skeleton for resolved markup, and useEffect fetches data after first paint. A test that grabs an elementHandle or sleeps for a fixed interval is racing all of this, which is why the same spec passes locally and fails under CI load. The reliable approach pairs Playwright's auto-waiting locators with state-aware waitFor(), and reaches for waitForFunction() or expect.poll() only when the condition lives in JavaScript state rather than the DOM. This page traces the root cause and gives tested patterns for Suspense, virtualized lists, and hydration gates. It extends the Handling Dynamic Content guide within Reliable Selector Strategies for Playwright.
Root cause: reconciliation detaches the node you captured
React's reconciler diffs the virtual tree and applies the minimum DOM mutations, which frequently means detaching a subtree and mounting a fresh one during a state change. Any reference you captured eagerly — a page.$() handle or an elementHandle — points at the old node and goes stale the instant React replaces it, surfacing as Element is not attached to the DOM. Fixed sleeps fail for the opposite reason: they encode a guess about timing that holds on a fast laptop and breaks on a loaded CI runner.
Playwright's locator API is built for exactly this. A locator is a lazy description, not a captured node; it re-resolves and retries on every action and assertion, so it absorbs most reconciliation gaps automatically. The remaining cases are multi-phase transitions the engine cannot infer — a Suspense fallback that must detach before the real component attaches, or readiness that lives in a store rather than the DOM. For those you add explicit, state-aware waits: waitFor({ state }) on a locator for DOM phases, and waitForFunction() or expect.poll() for JavaScript state. Throughout, prefer getByRole and data-testid over CSS so the locator survives React upgrades, exactly as the Optimizing XPath for SPA Navigation guide argues for route transitions.
Minimal reproducible example
This test synchronizes through a Suspense boundary: it confirms the skeleton, waits for the resolved region to attach and become visible, then interacts.
import { test, expect } from '@playwright/test';
test('waits through a Suspense boundary before interacting', async ({ page }) => {
await page.goto('/dashboard');
// Phase 1: the Suspense fallback skeleton is on screen first.
const skeleton = page.getByTestId('skeleton-loader');
await expect(skeleton).toBeVisible();
// Phase 2: wait for the skeleton to detach so we know React swapped it.
await skeleton.waitFor({ state: 'detached' });
// Resolve the real component by role; the locator re-evaluates lazily,
// so reconciliation cannot leave us holding a stale node.
const widget = page.getByRole('region', { name: 'Analytics Widget' });
// Confirm it is attached and visible before any action.
await widget.waitFor({ state: 'attached', timeout: 10000 });
await expect(widget).toBeVisible();
// Safe interaction: the inner button query also auto-waits.
await widget.getByRole('button', { name: 'Refresh' }).click();
});
Step-by-step fix
- Prefer locators over handles. Replace every
page.$()/elementHandlecapture with alocator(ideallygetByRoleorgetByTestId) so the reference re-resolves on each use and never goes stale across reconciliation. - Synchronize the fallback phase. Assert the loading skeleton is visible, then
waitFor({ state: 'detached' })on it, so the test proves React tore down the fallback before looking for the resolved component. - Choose the right element state. Use
state: 'attached'when the element exists but may be hidden, andstate: 'visible'when it must be rendered and unobscured before interaction. Picking the wrong state is a common silent flake source. - Use a realistic timeout, never a sleep. Pass an explicit
timeouttowaitForandexpectfor slow fetches instead ofwaitForTimeout. You widen the retry window without encoding a fixed guess. - Poll JavaScript state with the right tool. When readiness lives in a store or window flag rather than the DOM, use
expect.poll()for a Playwright value orwaitForFunction()for an in-page predicate, and target a specific item id rather than a count. - Verify with strict assertions. Close with
expect().toBeVisible()ortoBeInViewport()on the exact element you will act on, so strict locator mode catches ambiguity before it becomes flakiness.
Troubleshooting variants
A virtualized list item never appears
Virtualized feeds mount rows only as they scroll into view, so a query for a far-down item times out. Scroll the container, then wait for that specific item by id rather than a row count.
import { test, expect } from '@playwright/test';
test('waits for a virtualized list item after scroll', async ({ page }) => {
await page.goto('/feed');
// Drive the list to the bottom to trigger windowed rendering.
await page.getByTestId('virtual-list').evaluate((el) => {
el.scrollTop = el.scrollHeight;
});
// Poll for the exact item to exist, not a fragile nth-position.
await page.waitForFunction(
() => !!document.querySelector('[data-item-id="post-42"]'),
{ timeout: 15000 },
);
await expect(page.getByTestId('post-42')).toBeInViewport();
});
The component depends on hydration finishing
When interactivity requires hydration but no DOM change signals it, poll the app's readiness flag with expect.poll() before asserting on UI.
import { test, expect } from '@playwright/test';
test('waits for the React app to hydrate', async ({ page }) => {
await page.goto('/app');
// Poll a window flag the app sets once hydration completes.
await expect
.poll(() => page.evaluate(() => (window as { __APP_READY__?: boolean }).__APP_READY__), {
timeout: 10000,
intervals: [200, 500, 1000],
})
.toBe(true);
await expect(page.getByRole('main')).toBeVisible();
});
useEffect fetches make the test flaky
Unawaited fetches in useEffect resolve at unpredictable times. Mock the slow endpoint with page.route() during local debugging to get deterministic timing, and attach a DOM snapshot with test.info().attach() to diagnose the exact failure phase. This complements the synchronization patterns in the wider Handling Dynamic Content guide.
Verification
Prove the waits hold three ways. Run npx playwright test --repeat-each=10 on the spec — phase-aware waits stay green where a fixed sleep flaked. Enable trace: 'on-first-retry', force a failure, and step through the snapshots to confirm the skeleton detached before the resolved component query ran. Finally, throttle the network in DevTools or add latency via page.route() and rerun: explicit timeouts should absorb the delay rather than surfacing a stale-element error.
Frequently Asked Questions
When do I need waitForFunction instead of a locator wait?
Use a locator's waitFor() whenever the condition is a DOM element state. Reach for waitForFunction() (or expect.poll()) only when readiness lives in JavaScript — a store value, a window flag, or a computed property the DOM does not reflect.
Why is waitForTimeout discouraged?
A fixed sleep encodes a timing guess that holds on a fast machine and breaks under CI load, so it hides races rather than resolving them. State-aware waits retry until the real condition is met, which is both faster on average and reliable.
How do I wait for a Suspense fallback to disappear?
Treat it as two phases: assert the skeleton is visible, then waitFor({ state: 'detached' }) on it, and only then resolve and assert the real component. This proves React completed the swap instead of guessing a delay.