Handling server errors
Map API validation errors onto fields and report failed requests.
Client validation gives quick feedback, but only the server knows whether a username is taken or a request succeeded, and the server must validate every submission anyway. React Hook Form shows server errors through the same errors object as client errors, using setError.
Where each error goes
| The server says… | Show it with |
|---|---|
| A specific field is invalid ("username taken") | setError("username", { type: "server", message }) |
| The request failed for a reason not tied to a field | setError("root.server", { type: "500", message }) |
| The network request itself failed | setError("root.server", ...) with a "check your connection" message |
| The save succeeded | Your own success state, such as a status message |
root errors don't belong to any input. Use any key under root, such as root.server or root.payment.
How long server errors stay
- A field error stays until that field is validated again. Editing the field clears it, and editing other fields does not.
- A
rooterror stays until the user submits again. - Server errors do not block submission.
handleSubmitre-validates with your client rules, so the user can retry without changing anything.
A reusable helper
Map field errors from the server only onto fields the form knows about, and focus the first one so keyboard and screen reader users land on the problem:
// apply-server-errors.tsimport type { FieldPath, FieldValues, UseFormSetError } from "react-hook-form"// Applies field errors from the server to known fields, focusing the first.// Returns the names it applied, so the caller can handle the rest.export function applyServerErrors<T extends FieldValues>(setError: UseFormSetError<T>,fieldErrors: Record<string, string | undefined>,fields: readonly FieldPath<T>[]): FieldPath<T>[] {const applied = fields.filter((name) => fieldErrors[name])applied.forEach((name, index) =>setError(name,{ type: "server", message: fieldErrors[name] },{ shouldFocus: index === 0 }))return applied}
fields is checked against your form type, so a renamed field is a type error. Unknown keys in the response are ignored instead of creating errors no input displays.
Example
This form expects PUT /api/profile to return HTTP 200 when saved, or HTTP 422 with { "fieldErrors": { "username": "That username is taken." } }. Any other status shows a general message, using the server's message when it sends one.
import { useState } from "react"import { useForm } from "react-hook-form"import { applyServerErrors } from "./apply-server-errors"type ProfileValues = { username: string; email: string }// The API contract: 200 on success, 422 with field messages on validation failure.type ErrorResponse = { fieldErrors?: Record<string, string>; message?: string }const fields = ["username", "email"] as constexport default function ProfileForm() {const [saved, setSaved] = useState(false)const {register,handleSubmit,setError,formState: { errors, isSubmitting },} = useForm<ProfileValues>({ defaultValues: { username: "", email: "" } })const onSubmit = async (values: ProfileValues) => {setSaved(false)let response: Responsetry {response = await fetch("/api/profile", {method: "PUT",headers: { "Content-Type": "application/json" },body: JSON.stringify(values),})} catch {setError("root.server", {type: "network",message:"You appear to be offline. Check your connection and try again.",})return}if (response.ok) {setSaved(true)return}const body: ErrorResponse = await response.json().catch(() => ({}))if (response.status === 422 && body.fieldErrors) {const applied = applyServerErrors(setError, body.fieldErrors, fields)if (applied.length === Object.keys(body.fieldErrors).length) return}setError("root.server", {type: String(response.status),message:body.message ?? "We couldn't save your profile. Please try again.",})}return (<form noValidate onSubmit={handleSubmit(onSubmit)}><label htmlFor="username">Username</label><inputid="username"aria-invalid={!!errors.username}aria-describedby={errors.username ? "username-error" : undefined}{...register("username", { required: "Enter a username." })}/>{errors.username && (<p id="username-error" role="alert">{errors.username.message}</p>)}<label htmlFor="email">Email</label><inputid="email"type="email"aria-invalid={!!errors.email}aria-describedby={errors.email ? "email-error" : undefined}{...register("email", { required: "Enter your email address." })}/>{errors.email && (<p id="email-error" role="alert">{errors.email.message}</p>)}{errors.root?.server && <p role="alert">{errors.root.server.message}</p>}{saved && <p role="status">Profile saved.</p>}<button type="submit" disabled={isSubmitting}>{isSubmitting ? "Saving…" : "Save profile"}</button></form>)}
How it fits together:
awaitthe request inside the submit handler, soisSubmittingstaystrueand the button stays disabled until the response arrives.fetchonly rejects when the request can't be made. Checkresponse.okand the status yourself.- When the response contains a field the form doesn't have, the helper skips it and the form falls back to a general error, so the user is never left without feedback.
shouldFocusneeds the input'sref, so keep the input enabled and the ref attached.
Things to watch
- Validate on the server too. Client rules can be bypassed. With a schema resolver, share the same schema with the server, as in the multi-step form example.
- Don't show raw server messages. Map technical errors to messages a user can act on. Never show stack traces or internal details.
- Server Actions. With React 19 Server Actions, the same mapping applies to the action's return value. See Server Actions.
- Accessibility. Render
rooterrors withrole="alert"so they are announced. See Accessibility.