C.W.K.
Stream
Lesson 04 of 04 · published

Async UI and the Custom Render Helper

~18 min · vitest-components, async, waitFor, custom-render

Level 0Test Curious
0 XP0/32 lessons0/13 achievements
0/100 XP to next level100 XP to go0% complete
"Async UI needs queries that wait. Async tests need providers that arrive."

Async Queries — findBy, waitFor, waitForElementToBeRemoved

Most real UI is async: data loads, optimistic updates settle, toasts appear and disappear. Synchronous queries (getBy, queryBy) can't see elements that haven't appeared yet. Three tools handle this:

  • findBy* — returns a promise that polls until the element appears (default 1s timeout, 50 ms interval). Use when you're waiting for ONE specific element.
  • waitFor(callback) — runs the callback repeatedly until it doesn't throw, or the timeout expires. Use for assertions that aren't simple presence (a value updated, a mock got called).
  • waitForElementToBeRemoved(element) — polls until the element is gone. Use for spinners, toasts, modals closing.

Rule of thumb: prefer findBy over waitFor(() => expect(getBy...).toBeInTheDocument()). They do the same thing but findBy reads cleaner and produces better error messages.

The Common Pattern — Loading → Loaded

A page fetches data and shows a spinner, then the data, then maybe an error. The test reads like a screenplay:

  1. Render the component.
  2. Assert the spinner is present (getByRole('progressbar') or similar).
  3. await screen.findByText(/loaded data/i) — wait for the success state to appear.
  4. Optionally: expect(spinner).not.toBeInTheDocument() — assert the spinner is gone.
Async test failures usually mean stale assertions, not slow code. If a test fails with "unable to find element" after findBy timed out, the element you're waiting for is genuinely not appearing — either your mock isn't set up, or the component's loading logic is broken. Don't "fix" by bumping the timeout; investigate.

Custom Render — Where Providers Live

Production React trees are wrapped in providers: theme, router, query client, auth context. The default render doesn't know about these — each test would have to wrap every render manually. A custom render helper centralizes this.

The convention: create src/test-utils.tsx that re-exports everything from @testing-library/react plus a custom render that pre-wraps. Tests import from test-utils instead of @testing-library/react.

One Render Helper, Per-Test Options

The custom render takes optional configuration for routes, initial state, theme mode — whatever the test needs to vary. The default values match the most common case. A specific test that needs theme="dark" or starts at /dashboard passes those options without touching the rest of the suite.

Re-export everything from @testing-library/react. Then nobody has to remember whether to import screen from @testing-library/react or test-utils. One import path, one mental model.

Code

findBy — wait for an element to appear·tsx
// Async — findBy for elements that appear later
import { describe, it, expect } from 'vitest';
import { render, screen } from '@testing-library/react';
import { UserProfile } from './user-profile';

describe('<UserProfile />', () => {
  it('shows the spinner, then the name', async () => {
    render(<UserProfile id={1} />);

    // Immediately visible — synchronous query
    expect(screen.getByRole('progressbar')).toBeInTheDocument();

    // Appears after fetch resolves — async query
    const name = await screen.findByRole('heading', { name: /pippa choi/i });
    expect(name).toBeInTheDocument();

    // Optionally assert the spinner is gone
    expect(screen.queryByRole('progressbar')).not.toBeInTheDocument();
  });
});
waitFor + waitForElementToBeRemoved — for non-presence async·tsx
// waitFor — for assertions that aren't just 'element appeared'
import { describe, it, expect, vi } from 'vitest';
import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { Counter } from './counter';

it('eventually persists the count to localStorage', async () => {
  const setItem = vi.spyOn(Storage.prototype, 'setItem');
  const user = userEvent.setup();

  render(<Counter />);
  await user.click(screen.getByRole('button', { name: /increment/i }));

  // The persistence is debounced — wait until it actually fires.
  await waitFor(() => {
    expect(setItem).toHaveBeenCalledWith('count', '1');
  });
});

// waitForElementToBeRemoved — for spinners, toasts, modals closing
import { waitForElementToBeRemoved } from '@testing-library/react';

it('hides the loading toast after the request completes', async () => {
  render(<UploadForm />);
  // ... trigger upload
  await waitForElementToBeRemoved(() => screen.queryByText(/uploading/i));
  expect(screen.getByText(/upload complete/i)).toBeInTheDocument();
});
Custom render with providers — `src/test-utils.tsx`·tsx
// src/test-utils.tsx — your custom render that knows about providers
import { render, RenderOptions } from '@testing-library/react';
import { ReactElement, ReactNode } from 'react';
import { ThemeProvider } from './theme';
import { MemoryRouter } from 'react-router-dom';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';

type CustomRenderOptions = Omit<RenderOptions, 'wrapper'> & {
  initialRoute?: string;
  theme?: 'light' | 'dark';
};

function AllProviders({
  children,
  initialRoute = '/',
  theme = 'light',
}: { children: ReactNode } & CustomRenderOptions) {
  // A fresh QueryClient per test prevents cache bleed between tests.
  const queryClient = new QueryClient({
    defaultOptions: { queries: { retry: false } }, // tests shouldn't retry
  });

  return (
    <ThemeProvider mode={theme}>
      <QueryClientProvider client={queryClient}>
        <MemoryRouter initialEntries={[initialRoute]}>{children}</MemoryRouter>
      </QueryClientProvider>
    </ThemeProvider>
  );
}

export function renderWithProviders(
  ui: ReactElement,
  { initialRoute, theme, ...rtlOptions }: CustomRenderOptions = {}
) {
  return render(ui, {
    wrapper: ({ children }) => (
      <AllProviders initialRoute={initialRoute} theme={theme}>
        {children}
      </AllProviders>
    ),
    ...rtlOptions,
  });
}

// Re-export everything else so tests have one import path.
export * from '@testing-library/react';
export { renderWithProviders as render };
Using the custom render — clean tests, no provider boilerplate·tsx
// Tests import from test-utils, get providers for free.
import { describe, it, expect } from 'vitest';
import { render, screen } from './test-utils';   // ← custom, not @testing-library/react
import { Dashboard } from './dashboard';

describe('<Dashboard />', () => {
  it('renders the welcome banner', async () => {
    render(<Dashboard />);
    // ThemeProvider, MemoryRouter, QueryClientProvider all already in scope.
    expect(await screen.findByText(/welcome back/i)).toBeInTheDocument();
  });

  it('shows the dark-mode style when the theme option is dark', () => {
    render(<Dashboard />, { theme: 'dark' });
    expect(screen.getByRole('main')).toHaveClass('theme-dark');
  });

  it('starts at /reports when initialRoute is set', async () => {
    render(<Dashboard />, { initialRoute: '/reports' });
    expect(
      await screen.findByRole('heading', { name: /monthly reports/i })
    ).toBeInTheDocument();
  });
});

External links

Exercise

Build a <UserList /> that fetches /api/users via React Query and renders a list. Set up MSW to return three users. Write tests that: (1) shows a spinner initially (sync query), (2) shows three list items after the fetch resolves (findAllByRole('listitem')), (3) shows an error message when MSW returns 500. Build the custom render from test-utils.tsx so each test starts with a fresh QueryClient. Confirm the tests pass individually AND in any order.
Hint
If a test passes alone but fails when run with others, you're sharing state somewhere — usually the QueryClient or an MSW handler override that's leaking. The custom render + afterEach(() => server.resetHandlers()) combo prevents both.

Progress

Progress is local-only — sign in to sync across devices.
Spotted a bug or have feedback on this page?Report an Issue

Comments 0

🔔 Reply notifications (sign in)
Sign inPlease sign in to comment.

No comments yet — be the first.