Polish — Error Focus, Loading States, and Accessibility
The form works. Validation catches mistakes, steps navigate cleanly, and the data submits. But working isn't the same as polished. This final lesson focuses on the details that separate a functional form from a professional one: automatically focusing the first error, communicating loading state during submission, and making the entire form accessible to screen readers and keyboard users.
Auto-Focus on First Error
React Hook Form automatically focuses the first field that fails validation when the user submits. This behavior is controlled by shouldFocusError, and it's enabled by default:
const methods = useForm({
shouldFocusError: true, // this is the default — no need to set it
});
When the user clicks "Submit" and validation fails, RHF walks through the errors in DOM order and calls .focus() on the first one. This works because register attaches a ref to each input element. RHF holds a reference to every registered input's DOM node, so it can focus any of them programmatically.
You don't need to do anything to enable this — it just works. If you ever want to disable it (rare, but possible in complex layouts), set shouldFocusError: false.
Programmatic Focus with setFocus
Sometimes you want to focus a field outside the validation flow. setFocus lets you move focus to any registered field by name:
import { useEffect } from "react";
import { useForm } from "react-hook-form";
function ContactForm() {
const { register, setFocus } = useForm();
useEffect(() => {
setFocus("name");
}, [setFocus]);
return (
<form>
<input {...register("name")} placeholder="Name" />
<input {...register("email")} placeholder="Email" />
</form>
);
}
Calling setFocus("name") on mount puts the cursor in the name field immediately, so the user can start typing without clicking. This is useful anywhere you want to guide the user's attention — after clearing a form, after navigating to a new step, or after an error is resolved and you want to move them to the next field.
Loading States
isSubmitting
The formState object includes isSubmitting, which is true while your onSubmit handler runs. If your handler is async — making an API call, saving to a database — isSubmitting stays true until the promise resolves. Use it to disable the submit button and show feedback:
const {
handleSubmit,
formState: { isSubmitting },
} = useForm();
const onSubmit = async (data) => {
await fetch("/api/register", {
method: "POST",
body: JSON.stringify(data),
});
};
<button type="submit" disabled={isSubmitting}>
{isSubmitting ? "Submitting..." : "Submit Registration"}
</button>
Disabling the button during submission prevents double-clicks from creating duplicate entries. The text change gives the user a clear signal that something is happening.
isSubmitSuccessful
After submission completes, isSubmitSuccessful becomes true. You can use this to swap the form for a success message:
const {
handleSubmit,
formState: { isSubmitting, isSubmitSuccessful },
} = useForm();
if (isSubmitSuccessful) {
return (
<div role="status">
<h2>Registration Complete</h2>
<p>You're all set. Check your email for confirmation.</p>
</div>
);
}
return (
<form onSubmit={handleSubmit(onSubmit)}>
{/* form fields */}
</form>
);
This pattern replaces the entire form with a success state. No extra useState needed — RHF tracks it for you. The role="status" on the success container ensures screen readers announce it automatically.
Accessibility
A form that can't be used with a screen reader or keyboard isn't done. Here are the patterns that make your inputs accessible.
Labels
Always associate labels with inputs using htmlFor and a matching id:
<label htmlFor="email">Email</label>
<input id="email" {...register("email")} />
Without this association, screen readers can't tell the user what a field is for. The htmlFor/id pairing also lets users click the label to focus the input — a small but important usability improvement.
ARIA Attributes for Errors
Three attributes make error states accessible:
<input
id="email"
aria-invalid={!!errors.email}
aria-describedby={errors.email ? "email-error" : undefined}
{...register("email", { required: "Email is required" })}
/>
{errors.email && (
<p id="email-error" role="alert">
{errors.email.message}
</p>
)}
aria-invalidtells assistive technology that this field has an error. Screen readers announce "invalid entry" when the user focuses the input.aria-describedbylinks the input to its error message by ID. When the user focuses the field, the screen reader reads both the label and the error message.role="alert"on the error element makes screen readers announce the error message immediately when it appears, even if the user hasn't focused the field yet.
Complete Accessible Field Pattern
Combining labels, ARIA attributes, and error messages into one reusable pattern:
function AccessibleField({
id,
label,
error,
registration,
type = "text",
}: {
id: string;
label: string;
error?: { message?: string };
registration: ReturnType<typeof register>;
type?: string;
}) {
return (
<div>
<label htmlFor={id}>{label}</label>
<input
id={id}
type={type}
aria-invalid={!!error}
aria-describedby={error ? `${id}-error` : undefined}
{...registration}
/>
{error?.message && (
<p id={`${id}-error`} role="alert" style={{ color: "red" }}>
{error.message}
</p>
)}
</div>
);
}
This component handles the htmlFor/id pairing, conditional ARIA attributes, and error rendering in one place. Use it across every field in your form to keep your accessibility consistent.
The Polished EventRegistration
Here's the updated EventRegistration component with all polish applied — error focus, loading states, accessible markup, and the success message pattern:
import { useState, useEffect } from "react";
import { useForm, FormProvider, useFormContext } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
const eventSchema = z.object({
name: z.string().min(2, "Name must be at least 2 characters"),
email: z.string().email("Invalid email address"),
phone: z.string().optional(),
ticketType: z.enum(["general", "vip", "student"], {
required_error: "Select a ticket type",
}),
guests: z
.array(
z.object({
name: z.string().min(1, "Guest name is required"),
email: z.string().email("Invalid email"),
})
)
.optional(),
dietary: z
.enum(["none", "vegetarian", "vegan", "gluten-free"])
.default("none"),
accessibility: z.string().optional(),
});
type EventFormValues = z.infer<typeof eventSchema>;
const stepFields: Record<number, (keyof EventFormValues)[]> = {
0: ["name", "email", "phone"],
1: ["ticketType", "guests"],
2: ["dietary", "accessibility"],
};
const stepLabels = ["Attendee Info", "Tickets & Guests", "Preferences & Review"];
function EventRegistration() {
const [currentStep, setCurrentStep] = useState(0);
const methods = useForm<EventFormValues>({
resolver: zodResolver(eventSchema),
defaultValues: {
name: "",
email: "",
phone: "",
ticketType: undefined,
guests: [],
dietary: "none",
accessibility: "",
},
mode: "onTouched",
shouldFocusError: true,
});
const {
handleSubmit,
trigger,
setFocus,
formState: { isSubmitting, isSubmitSuccessful },
} = methods;
useEffect(() => {
const firstField = stepFields[currentStep]?.[0];
if (firstField) {
setFocus(firstField);
}
}, [currentStep, setFocus]);
async function handleNext() {
const isValid = await trigger(stepFields[currentStep]);
if (isValid) {
setCurrentStep((prev) => prev + 1);
}
}
function handleBack() {
setCurrentStep((prev) => prev - 1);
}
async function onSubmit(data: EventFormValues) {
await fetch("/api/register", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(data),
});
}
if (isSubmitSuccessful) {
return (
<div role="status">
<h2>Registration Complete</h2>
<p>You're all set. Check your email for a confirmation with your event details.</p>
</div>
);
}
// Step components would be imported — shown inline here for clarity
const steps = [StepAttendeeInfo, StepTickets, StepPreferences];
const StepComponent = steps[currentStep];
return (
<FormProvider {...methods}>
<form onSubmit={handleSubmit(onSubmit)} noValidate>
<nav aria-label="Registration steps" style={{ display: "flex", gap: "16px", marginBottom: "24px" }}>
{stepLabels.map((label, index) => (
<span
key={label}
aria-current={index === currentStep ? "step" : undefined}
style={{
fontWeight: index === currentStep ? "bold" : "normal",
color: index === currentStep ? "#000" : "#999",
}}
>
{index + 1}. {label}
</span>
))}
</nav>
<StepComponent />
<div style={{ display: "flex", gap: "8px", marginTop: "24px" }}>
{currentStep > 0 && (
<button type="button" onClick={handleBack}>
Back
</button>
)}
{currentStep < steps.length - 1 ? (
<button type="button" onClick={handleNext}>
Next
</button>
) : (
<button type="submit" disabled={isSubmitting}>
{isSubmitting ? "Submitting..." : "Submit Registration"}
</button>
)}
</div>
</form>
</FormProvider>
);
}
The key additions: shouldFocusError: true is explicit for clarity (it's the default). The setFocus call in the useEffect focuses the first field of each step when the user navigates. The submit button disables and shows "Submitting..." during the async handler. After success, the form swaps out for a confirmation message with role="status". The nav uses aria-label and aria-current="step" so screen readers can communicate the step indicator. The noValidate attribute on the form element prevents browser-native validation tooltips, letting RHF handle all validation feedback.
Course Wrap-Up
Over twenty lessons you've built a complete understanding of React Hook Form, from the ground up:
- Foundations — how
registerworks,formState, and the uncontrolled-first architecture that makes RHF fast - Validation — built-in rules, custom validators, validation modes, and full schema validation with Zod
- Advanced Patterns —
Controllerfor controlled components,FormProviderfor nested components,useWatchfor reactive fields,useFieldArrayfor dynamic lists, andresetfor external data - Capstone — a multi-step event registration form that combined everything: Zod schemas, step navigation with
trigger, field arrays, context, and now accessibility and loading states
From here, a few resources to keep building:
- React Hook Form DevTools — install
@hookform/devtoolsand add<DevTool control={control} />to any form. It shows live form state, field values, validation status, and re-render counts in a floating panel. Invaluable for debugging complex forms. - The official docs at react-hook-form.com — the API reference covers every option and edge case. The FAQs section is particularly useful for migration questions and integration patterns.
- Other resolvers — we used Zod throughout this course, but
@hookform/resolverssupports Yup, Superstruct, Joi, Vest, and others. If your team already uses a different validation library, swap the resolver and your forms keep working the same way.
You now have the tools to build forms that are fast, validated, accessible, and maintainable. Go build something.