Testing React Hook Form Submissions
React Hook Form is fast because it avoids re-rendering on every keystroke: registered inputs are uncontrolled, and the library reads their values from the DOM when it needs them. That design has testing consequences. Values set programmatically without events are invisible to it, controlled third-party inputs must go through Controller, submission is asynchronous even when nothing asynchronous happens, and much of the behaviour worth testing — focus on the first invalid field, resolver errors, default values and reset — depends on configuration that is easy to get subtly wrong. This guide covers testing React Hook Form forms through the DOM with user-event, verifying the submit and invalid handlers, and pinning the configuration choices that change user-visible behaviour. It sits under form and validation testing.
Root Cause Analysis
The most common React Hook Form testing bug is filling fields in a way the library cannot see. Setting input.value directly, or using a helper that does, changes the DOM without dispatching the input events React Hook Form listens for; with some validation modes the library then reads a stale value or reports the field as untouched. The form submits the wrong data or fails validation in the test while working perfectly for users.
The second is asynchrony. handleSubmit returns a function that runs validation — possibly asynchronous, with a resolver — before calling your submit handler. A test that clicks submit and immediately asserts the handler was called fails intermittently depending on whether validation resolved within the same tick. Awaiting the click, or waiting for the handler, removes the race.
The third is configuration drift. mode, reValidateMode, shouldFocusError, defaultValues and resetOptions each change what users see, and each has a default that has changed between major versions. A form whose behaviour depends on a default nobody chose explicitly will change behaviour on upgrade, and only a test that pins the behaviour will notice.
Reproducible Setup
A shipping form using a Zod resolver, one registered input, one controlled third-party select through Controller, and a numeric field that the schema coerces.
// src/checkout/ShippingForm.tsx
import { useForm, Controller } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';
import { CountrySelect } from '@acme/ui';
export const shippingSchema = z.object({
name: z.string().trim().min(1, 'Enter the recipient’s name'),
country: z.string().min(2, 'Choose a country'),
floor: z.coerce.number().int().min(0, 'Floor cannot be negative').optional(),
});
export type Shipping = z.infer<typeof shippingSchema>;
export function ShippingForm({ onSubmit, defaults }: { onSubmit: (s: Shipping) => Promise<void>; defaults?: Partial<Shipping> }) {
const { register, control, handleSubmit, formState: { errors, isSubmitting } } = useForm<Shipping>({
resolver: zodResolver(shippingSchema), mode: 'onBlur', shouldFocusError: true, defaultValues: { name: '', country: '', ...defaults },
});
return (
<form onSubmit={handleSubmit(onSubmit)} noValidate aria-label="Shipping details">
<label htmlFor="name">Recipient name</label>
<input id="name" {...register('name')} aria-invalid={!!errors.name} aria-describedby={errors.name ? 'name-err' : undefined} />
{errors.name && <p id="name-err" role="alert">{errors.name.message}</p>}
<Controller name="country" control={control} render={({ field, fieldState }) => (
<CountrySelect label="Country" value={field.value} onChange={field.onChange} onBlur={field.onBlur} error={fieldState.error?.message} />
)} />
<label htmlFor="floor">Floor (optional)</label>
<input id="floor" inputMode="numeric" {...register('floor')} />
<button type="submit" disabled={isSubmitting}>{isSubmitting ? 'Saving…' : 'Continue to payment'}</button>
</form>
);
}
Implementation
Step 1 — Fill fields with user-event and assert on the transformed payload. The resolver trims the name and coerces the floor to a number; the handler should receive the parsed values, not the raw strings.
// src/checkout/ShippingForm.test.tsx
import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { ShippingForm } from './ShippingForm';
test('submits trimmed, coerced values', async () => {
const user = userEvent.setup();
const onSubmit = vi.fn().mockResolvedValue(undefined);
render(<ShippingForm onSubmit={onSubmit} />);
await user.type(screen.getByLabelText('Recipient name'), ' Ada Lovelace ');
await user.selectOptions(screen.getByLabelText('Country'), 'GB');
await user.type(screen.getByLabelText('Floor (optional)'), '3');
await user.click(screen.getByRole('button', { name: 'Continue to payment' }));
await waitFor(() => expect(onSubmit).toHaveBeenCalledOnce());
expect(onSubmit.mock.calls[0][0]).toEqual({ name: 'Ada Lovelace', country: 'GB', floor: 3 });
});
Asserting on the parsed payload rather than on the raw inputs is the point of this test. The user typed spaces and a string; the server should receive a trimmed name and an integer. Those transformations live in the schema, run inside the resolver, and are invisible to any test that only checks the input fields’ values — yet they are exactly where a server-side validation error or a subtle data-quality problem would originate.
Step 2 — Assert focus moves to the first invalid field. With shouldFocusError, a failed submission focuses the first field in error, which is how keyboard users find the problem.
test('focuses the first invalid field on a failed submit', async () => {
const user = userEvent.setup();
render(<ShippingForm onSubmit={vi.fn()} />);
await user.click(screen.getByRole('button', { name: 'Continue to payment' }));
await waitFor(() => expect(screen.getByLabelText('Recipient name')).toHaveFocus());
expect(screen.getByLabelText('Recipient name')).toHaveAccessibleDescription('Enter the recipient’s name');
});
Step 3 — Pin the validation mode. With mode: 'onBlur', typing alone shows no error; leaving the field does.
test('validates the name when the field loses focus', async () => {
const user = userEvent.setup();
render(<ShippingForm onSubmit={vi.fn()} />);
await user.click(screen.getByLabelText('Recipient name'));
expect(screen.queryByRole('alert')).not.toBeInTheDocument();
await user.tab();
expect(await screen.findByRole('alert')).toHaveTextContent('Enter the recipient’s name');
});
Step 4 — Exercise the Controller field through its own accessible surface. Third-party inputs are the fields most likely to break the wiring; interact with them the way a user would and assert the value reaches the handler.
test('the country select’s value reaches the form', async () => {
const user = userEvent.setup();
const onSubmit = vi.fn().mockResolvedValue(undefined);
render(<ShippingForm onSubmit={onSubmit} defaults={{ name: 'Ada' }} />);
await user.selectOptions(screen.getByLabelText('Country'), 'FR');
await user.click(screen.getByRole('button', { name: 'Continue to payment' }));
await waitFor(() => expect(onSubmit).toHaveBeenCalledWith(expect.objectContaining({ country: 'FR' }), expect.anything()));
});
Step 5 — Test defaults and reset. Editing an existing address pre-fills the form; after a successful save the form should either reset to the saved values or clear, depending on the product decision — pin it.
test('pre-fills from defaults and keeps saved values after submit', async () => {
const user = userEvent.setup();
render(<ShippingForm onSubmit={vi.fn().mockResolvedValue(undefined)} defaults={{ name: 'Ada', country: 'GB' }} />);
expect(screen.getByLabelText('Recipient name')).toHaveValue('Ada');
await user.click(screen.getByRole('button', { name: 'Continue to payment' }));
await waitFor(() => expect(screen.getByLabelText('Recipient name')).toHaveValue('Ada'));
});
Step 6 — Test the submitting state. isSubmitting stays true while the handler’s promise is pending; a slow handler lets the test observe it.
test('disables the button while the submit handler runs', async () => {
let resolve!: () => void;
const onSubmit = vi.fn(() => new Promise<void>((r) => { resolve = r; }));
const user = userEvent.setup();
render(<ShippingForm onSubmit={onSubmit} defaults={{ name: 'Ada', country: 'GB' }} />);
await user.click(screen.getByRole('button', { name: 'Continue to payment' }));
expect(await screen.findByRole('button', { name: 'Saving…' })).toBeDisabled();
resolve();
expect(await screen.findByRole('button', { name: 'Continue to payment' })).toBeEnabled();
});
A last practical point concerns shared form setup. Once several forms use the same resolver pattern, error-rendering component and focus behaviour, extract a tested FormField wrapper that wires aria-invalid, aria-describedby and the alert together. The accessibility assertions then need to be proved once for the wrapper, and each form’s tests can concentrate on its own rules and submission behaviour.
Verification
Confirm the tests depend on React Hook Form seeing real input events: replace user.type in the first test with a direct value assignment. The payload assertion should fail or the handler never be called, which proves the test exercises the path users take.
npx vitest run src/checkout/ShippingForm.test.tsx --reporter=verbose
# ✓ submits trimmed, coerced values
# ✓ focuses the first invalid field on a failed submit
# ✓ validates the name when the field loses focus
# ✓ the country select’s value reaches the form
Then confirm the mode test pins behaviour by changing mode to 'onChange'. The “validates on blur” test should fail, since the error now appears while typing — exactly the kind of change an upgrade could introduce silently.
Troubleshooting
Symptom: the submit handler is never called although the fields look valid. Diagnosis: a field was filled without input events, so the resolver sees an empty value and validation fails silently. Fix: fill with user-event, and pass an onInvalid spy to handleSubmit in the test to see which field failed.
Symptom: the handler receives strings where numbers were expected. Diagnosis: the schema does not coerce, or valueAsNumber is missing on the registration. Fix: coerce in the schema, as the example does, and assert on the payload type in the test.
Symptom: a Controller field’s value never reaches the form. Diagnosis: the third-party component calls onChange with an event object rather than a value, or not at all for keyboard selection. Fix: adapt the value in the render callback, and add a keyboard-driven test for the component.
Symptom: “act” warnings after the test finishes. Diagnosis: the submit handler resolved after the last assertion, updating isSubmitting on an unmounted or finished render. Fix: wait for the settled state — the button’s idle label — before the test ends.
FAQ
Should I test React Hook Form’s own validation?
No — test your schema and your form’s behaviour. The library’s job is to connect them, and the tests above verify that connection through its observable effects without testing the library’s internals.
Is waitFor around the handler assertion necessary?
Yes, because handleSubmit runs the resolver asynchronously before calling the handler. Awaiting the click is usually enough in practice, but waitFor makes the test robust to resolvers that take more than one tick, such as those with asynchronous refinements.
How do I test field arrays?
Use useFieldArray through the interface: click “Add item”, fill the new row by its label, remove a row, and assert on the submitted array. Label each row’s inputs distinctly — “Item 2 quantity” — so queries are unambiguous, which also helps screen-reader users.
What about forms without React Hook Form?
The tests change very little. Because every interaction and assertion goes through the DOM, the same tests work for Formik, TanStack Form or plain controlled state; only the setup in the component differs. That independence is a strong reason to avoid assertions on library-specific state.
Related
- Back to Form & Validation Testing
- Testing Zod schema validation messages — the resolver’s rules tested directly.
- Testing form error recovery and retry — what happens after the handler fails.
- Keyboard navigation testing with user-event — focus and tab order in forms.