| | |

React Hook Form 07 ๐Ÿ“‹ Error Handling โ€” Reading the errors object and displaying messages

โฑ๏ธ Estimated read: 14 min | |

The errors object is the part of the formState that holds the validation failures. It is a nested object that mirrors the shape of the form values: a top-level field’s error is errors.email, a nested field’s error is errors.address.city, and an array field’s error is errors.items[0].name or errors.items.0.name. Each error object has a type property that names the rule that failed, a message property that holds the human-readable text, and a ref property that points to the DOM element. The nested structure is the mechanism that lets the UI read the error for the specific field without parsing a flat list of messages, and the type property is the mechanism that lets the UI distinguish the failure modes when the same field has multiple rules.

The errors object is a part of the formState proxy, which means the component re-renders only when the error properties it reads actually change. A component that reads errors.email does not re-render when errors.password changes, and a component that reads errors.email.message does not re-render when errors.email.type changes without the message changing . The narrow subscription is the mechanism that keeps the error rendering proportional to the actual changes, and it is the reason the error display does not become the performance bottleneck that the controlled-form approach would create.

Key point: The errors object mirrors the form values’ shape. Each error has a type, a message, and a ref. The formState.errors is a proxy, and the component re-renders only when the error properties it reads change. The type is the rule name, and the message is the string the rule returned. The root-level errors are under errors.root, and the field-level errors are under the field’s path.


Why the errors object is the display mechanism

The shape problem. A form value can be a string, an object, an array, or a nested combination of the three. The error for the value must follow the same shape, or the UI would have to flatten the structure to find the error. The errors object mirrors the values’ shape, so the UI reads the error at the same path it reads the value . The errors.address.city is the error for the address.city field, and the errors.items.0.name is the error for the first item’s name. The mirroring is the mechanism that keeps the display code simple.

The type problem. A field can have multiple rulesโ€”the required and the minLength and the pattern on the email field. The errors.email holds the error for the first failing rule, and the errors.email.type is the rule’s name . The type is the mechanism that lets the UI show the different messages for the different rules, and it is the mechanism that lets the test assert on the specific rule rather than the message string.

The root problem. Some errors do not belong to any fieldโ€”the network failure, the server error, the form-level validation failure. The errors.root is the object that holds these errors, and the root.server and root.generic are the keys the library uses . The root errors are the mechanism that keeps the form-level errors separate from the field-level errors, and the display is the separate component that reads the errors.root.

The proxy problem. The errors object is a proxy that tracks which properties the component reads. A component that reads errors.email re-renders when the errors.email reference changes, and a component that reads errors.email.message re-renders when the message changes. The proxy is the mechanism that makes the narrow subscription possible, and the narrow subscription is the reason the error display does not re-render the whole form.

The accessibility problem. An error message that is visually displayed but not announced to the screen reader is an error the user with the screen reader does not know about. The role="alert" on the error element, the aria-invalid on the input, and the aria-describedby connecting the input to the error are the mechanisms that make the error accessible . The accessibility is the separate concern from the display, and the error object is the data both concerns read.


a. The errors object structure and the field-level display

The errors object mirrors the form values. The top-level field’s error is errors.fieldName, the nested field’s error is errors.parent.child, and the array field’s error is errors.items[index].field. The error object has the type, the message, and the ref properties.

// The errors object structure
const {
  formState: { errors },
} = useForm<LoginFormValues>();

// The top-level field
errors.email?.message; // "The email is required."
errors.email?.type;    // "required"

// The nested field
errors.address?.city?.message; // "The city is required."

// The array field
errors.items?.[0]?.name?.message; // "The name is required."

The field-level display is the component that reads the specific error. The component reads the errors.fieldName, and the proxy re-renders it only when that error changes . The narrow read is the mechanism that keeps the re-renders proportional to the changes.

// The field-level display
function EmailField() {
  const {
    register,
    formState: { errors },
  } = useForm<LoginFormValues>();

  return (
    <div>
      <input
        {...register("email")}
        aria-invalid={errors.email ? "true" : "false"}
        aria-describedby={errors.email ? "email-error" : undefined}
      />
      {errors.email && (
        <p id="email-error" role="alert">
          {errors.email.message}
        </p>
      )}
    </div>
  );
}

The aria-invalid is the attribute that tells the screen reader the field is invalid, and the aria-describedby is the attribute that connects the input to the error element. The role="alert" is the attribute that makes the screen reader announce the error when it appears . The three attributes are the accessibility mechanism, and the errors.email is the data they read.

The ErrorMessage component from the @hookform/error-message package is the alternative. The component accepts the errors object, the name, and the render function, and it renders the message when the error exists.

// The ErrorMessage component
import { ErrorMessage } from "@hookform/error-message";

function EmailField() {
  const { register, formState: { errors } } = useForm();

  return (
    <div>
      <input {...register("email")} />
      <ErrorMessage
        errors={errors}
        name="email"
        render={({ message }) => <p role="alert">{message}</p>}
      />
    </div>
  );
}

The ErrorMessage is the convenience, and the manual errors.email && <p> is the equivalent. The package is the optional dependency, and the manual rendering is the mechanism that keeps the dependency count low.


b. The root errors and the multiple-rule display

The root errors are the errors that do not belong to any field. The setError("root.server", { ... }) is the mechanism that stores the server error under the root, and the errors.root?.server is the path the UI reads.

// The root error display
function FormError() {
  const {
    formState: { errors },
  } = useForm();

  return (
    <>
      {errors.root?.server && (
        <p role="alert">{errors.root.server.message}</p>
      )}
      {errors.root?.generic && (
        <p role="alert">{errors.root.generic.message}</p>
      )}
    </>
  );
}

The root.server is the server error, and the root.generic is the general error. The two keys are the library’s convention, and the errors.root is the object that holds them . The root display is the separate component, and the separation is the mechanism that keeps the form-level errors out of the field-level components.

The multiple-rule display is the component that reads the type to show the different messages for the different rules. The errors.email.type is the rule name, and the switch on the type is the mechanism that maps the rule to the message.

// The multiple-rule display
function EmailField() {
  const { register, formState: { errors } } = useForm();

  const getMessage = () => {
    if (!errors.email) return null;
    switch (errors.email.type) {
      case "required":
        return "The email is required.";
      case "pattern":
        return "The email format is invalid.";
      default:
        return "The email is invalid.";
    }
  };

  return (
    <div>
      <input {...register("email")} />
      {errors.email && <p role="alert">{getMessage()}</p>}
    </div>
  );
}

The switch on the type is the mechanism that distinguishes the rules. The message is the string the rule returned, and the type is the rule’s name. The two are the data the display reads, and the display is the component that maps them to the user-facing text.

The criteriaMode option is the mechanism that changes the error shape for the multiple rules on the same field. The default is "firstError", and the "all" is the mode that collects every failing rule’s error into the errors.email.types object.

// The criteriaMode: "all"
const { register, formState: { errors } } = useForm({
  criteriaMode: "all",
});

// The errors.email.types is the object of the failed rules.
errors.email?.types?.required; // "The email is required."
errors.email?.types?.pattern;  // "The email format is invalid."

The criteriaMode: "all" is the mechanism that shows every rule’s message, and the types object is the collection. The default "firstError" is the mechanism that shows only the first failing rule, and the choice is the UX decision.


Complete Example Session

// ============================================
// PART 1: THE BASIC ERROR DISPLAY
// ============================================

import { useForm } from "react-hook-form";

function LoginForm() {
  const { register, handleSubmit, formState: { errors } } = useForm({
    defaultValues: { email: "", password: "" },
  });

  return (
    <form onSubmit={handleSubmit((data) => console.log(data))} noValidate>
      <input {...register("email", { required: "The email is required." })} />
      {errors.email && <p role="alert">{errors.email.message}</p>}
      <input {...register("password", { required: "The password is required." })} type="password" />
      {errors.password && <p role="alert">{errors.password.message}</p>}
    </form>
  );
}

// ============================================
// PART 2: THE NESTED ERROR
// ============================================

<input {...register("address.city", { required: "The city is required." })} />
{errors.address?.city && <p role="alert">{errors.address.city.message}</p>}

// The nested error mirrors the nested field.

// ============================================
// PART 3: THE ARRAY ERROR
// ============================================

<input {...register("items.0.name", { required: "The name is required." })} />
{errors.items?.[0]?.name && <p role="alert">{errors.items[0].name.message}</p>}

// The array error uses the index path.

// ============================================
// PART 4: THE ERROR TYPE
// ============================================

<input
  {...register("email", {
    required: "The email is required.",
    pattern: { value: /^\S+@\S+$/i, message: "The email is invalid." },
  })}
/>
{errors.email && <p role="alert">{errors.email.type}: {errors.email.message}</p>}

// The type is the failed rule name.

// ============================================
// PART 5: THE ACCESSIBILITY ATTRIBUTES
// ============================================

<input
  {...register("email")}
  aria-invalid={errors.email ? "true" : "false"}
  aria-describedby={errors.email ? "email-error" : undefined}
/>
{errors.email && <p id="email-error" role="alert">{errors.email.message}</p>}

// The aria attributes connect the input to the error.

// ============================================
// PART 6: THE ROOT ERROR
// ============================================

{errors.root?.server && <p role="alert">{errors.root.server.message}</p>}

// The root error is the form-level error.

// ============================================
// PART 7: THE MULTIPLE RULES
// ============================================

const { register, formState: { errors } } = useForm({
  criteriaMode: "all",
});

// The errors.email.types holds every failed rule.

// ============================================
// PART 8: THE ERRORMESSAGE COMPONENT
// ============================================

import { ErrorMessage } from "@hookform/error-message";

<ErrorMessage
  errors={errors}
  name="email"
  render={({ message }) => <p role="alert">{message}</p>}
/>

// The component is the optional convenience.

// ============================================
// PART 9: THE NARROW SUBSCRIPTION
// ============================================

function ErrorSummary() {
  const { formState: { errors } } = useForm();

  // The component reads only errors.
  // It re-renders only when errors changes.
  return <>{Object.keys(errors).length > 0 && <p>There are errors.</p>}</>;
}

// The narrow read keeps the re-renders proportional.

// ============================================
// PART 10: THE CHECKLIST
// ============================================

// [ ] The errors object mirrors the form values.
// [ ] The error has the type and the message.
// [ ] The root errors are under errors.root.
// [ ] The aria attributes connect the input to the error.
// [ ] The criteriaMode: "all" collects every rule's error.
// [ ] The ErrorMessage component is the optional convenience.

The ten parts covered the basic error display, the nested error, the array error, the error type, the accessibility attributes, the root error, the multiple rules, the ErrorMessage component, the narrow subscription, and the checklist. The through-line is that the errors object mirrors the values, and the display reads the specific path.


Quick Reference

The error object

PropertyTypePurpose
typestringThe failed rule name
messagestringThe error message
refHTMLElementThe DOM element
typesobjectThe multiple rules (with criteriaMode: "all")

The error paths

Field typePath
The top-levelerrors.email
The nestederrors.address.city
The arrayerrors.items[0].name
The rooterrors.root.server

The accessibility attributes

AttributePurpose
role="alert"Announce the error to the screen reader
aria-invalidMark the input as invalid
aria-describedbyConnect the input to the error element

The criteriaMode

ModeBehavior
"firstError"The first failing rule’s error
"all"Every failing rule’s error in the types object

Best Practices

โœ… Do This:

// Read the specific error path
{errors.email && <p role="alert">{errors.email.message}</p>}       // โœ…

// Use the aria attributes
aria-invalid={errors.email ? "true" : "false"}                     // โœ…

// Use the criteriaMode: "all" for the multiple messages
useForm({ criteriaMode: "all" })                                   // โœ…

โŒ Don’t Do This:

// Read the whole formState in the error display
const { formState } = useForm(); // the wider re-render             // โŒ

// Assume the nested error is a flat key
errors["address.city"] // the wrong path                           // โŒ

Common Pitfalls

PitfallWhy It HappensFix
The error does not renderThe wrong path is usedCheck the nested path
The screen reader does not announceThe role="alert" is missingAdd the attribute
The multiple rules show one messageThe criteriaMode is not "all"Set the mode
The form re-renders on every keystrokeThe errors is read in the wrong scopeRead the specific path

Real-World Examples

1. The basic error

{errors.email && <p role="alert">{errors.email.message}</p>}

2. The nested error

{errors.address?.city && <p>{errors.address.city.message}</p>}

3. The array error

{errors.items?.[0]?.name && <p>{errors.items[0].name.message}</p>}

4. The error type

{errors.email?.type === "pattern" && <p>The format is invalid.</p>}

5. The root error

{errors.root?.server && <p role="alert">{errors.root.server.message}</p>}

6. The aria attributes

<input aria-invalid={errors.email ? "true" : "false"} />

7. The criteriaMode

useForm({ criteriaMode: "all" })

8. The types object

errors.email?.types?.required

9. The ErrorMessage component

<ErrorMessage errors={errors} name="email" render={({ message }) => <p>{message}</p>} />

10. The narrow subscription

const { formState: { errors } } = useForm();

Visual

The Error Object Structure

โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”
โ”‚  THE errors OBJECT MIRRORS THE VALUES                        โ”‚
โ”‚                                                              โ”‚
โ”‚  The values              The errors                          โ”‚
โ”‚  โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”     โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”                โ”‚
โ”‚  โ”‚ email: ""       โ”‚     โ”‚ email: {        โ”‚                โ”‚
โ”‚  โ”‚ address: {      โ”‚     โ”‚   type,         โ”‚                โ”‚
โ”‚  โ”‚   city: ""      โ”‚     โ”‚   message       โ”‚                โ”‚
โ”‚  โ”‚ }               โ”‚     โ”‚ }               โ”‚                โ”‚
โ”‚  โ”‚                 โ”‚     โ”‚ address: {      โ”‚                โ”‚
โ”‚  โ”‚                 โ”‚     โ”‚   city: {...}   โ”‚                โ”‚
โ”‚  โ”‚                 โ”‚     โ”‚ }               โ”‚                โ”‚
โ”‚  โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜     โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜                โ”‚
โ”‚                                                              โ”‚
โ”‚  The UI reads the same path it reads the value.             โ”‚
โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜

The Field Display

โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”
โ”‚  THE FIELD DISPLAY                                           โ”‚
โ”‚                                                              โ”‚
โ”‚  The component reads errors.email                            โ”‚
โ”‚         โ”‚                                                    โ”‚
โ”‚         โ–ผ                                                    โ”‚
โ”‚  โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”                                    โ”‚
โ”‚  โ”‚ The proxy re-rendersโ”‚  Only when errors.email changes    โ”‚
โ”‚  โ”‚ the component       โ”‚                                    โ”‚
โ”‚  โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜                                    โ”‚
โ”‚             โ”‚                                                โ”‚
โ”‚             โ–ผ                                                    โ”‚
โ”‚  โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”                                    โ”‚
โ”‚  โ”‚ The error message   โ”‚  The aria attributes               โ”‚
โ”‚  โ”‚ is rendered         โ”‚  connect the input                 โ”‚
โ”‚  โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜                                    โ”‚
โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜

The Root Error

โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”
โ”‚  THE ROOT ERROR                                              โ”‚
โ”‚                                                              โ”‚
โ”‚  setError("root.server", { message: "Network error" })       โ”‚
โ”‚         โ”‚                                                    โ”‚
โ”‚         โ–ผ                                                    โ”‚
โ”‚  โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”                                    โ”‚
โ”‚  โ”‚ errors.root.server  โ”‚  The form-level error              โ”‚
โ”‚  โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜                                    โ”‚
โ”‚             โ”‚                                                โ”‚
โ”‚             โ–ผ                                                    โ”‚
โ”‚  โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”                                    โ”‚
โ”‚  โ”‚ The separate        โ”‚  The error is rendered outside     โ”‚
โ”‚  โ”‚ component reads it  โ”‚  the field components              โ”‚
โ”‚  โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜                                    โ”‚
โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜

Summary

ItemValue
The error objecttype, message, ref, types
The top-level patherrors.fieldName
The nested patherrors.parent.child
The array patherrors.items[index].field
The root patherrors.root.key
The accessibilityrole="alert", aria-invalid, aria-describedby
The criteriaMode"firstError" or "all"

Key takeaways:

  • The errors object mirrors the form values’ shape. The top-level field’s error is errors.email, the nested field’s error is errors.address.city, and the array field’s error is errors.items[0].name. The mirroring is the mechanism that lets the UI read the error at the same path it reads the value .
  • Each error has a type and a message. The type is the failed rule name, and the message is the string the rule returned. The two are the data the display reads, and the switch on the type is the mechanism that maps the rule to the message .
  • The root errors are under errors.root. The root.server and the root.generic are the keys for the server and the general errors, and the separate component is the mechanism that keeps the form-level errors out of the field-level display .
  • The criteriaMode: "all" collects every failed rule’s error. The errors.email.types is the object of the failed rules, and the default "firstError" shows only the first failing rule’s message .
  • The accessibility attributes are the mechanism that announces the errors. The role="alert", the aria-invalid, and the aria-describedby are the attributes that connect the input to the error, and the screen reader is the mechanism that reads them .

Remember: The React Hook Form error handling is the errors object that mirrors the values, and the display that reads the specific path. The type is the rule name, the message is the error text, and the root is the form-level errors. The criteriaMode: "all" is the multiple rules, and the accessibility attributes are the announcement. The narrow read is the mechanism that keeps the re-renders proportional.

Was this helpful?


Stop using slow, ad-bloated tool sites! ๐Ÿคฎ

๐Ÿ”Ž Search “KandZ Tools” on Google to use many professional utilities for free.

KandZ.me is the ultimate minimalist hub for:
โœ… Finance (Mortgage, Interest, Inflation)
โœ… Tech (Base64, JSON, Dev Suite, IP)
โœ… Health (BMI, BMR, TDEE)
โœ… Productivity (Timer, Workspace, QR)

โšก๏ธ Fast & Private
๐Ÿ”’ No data leaves your device
๐Ÿ’Ž 100% Free

๐Ÿ”— Use it now: https://tools.kandz.me
๐Ÿ”– Bookmark itโ€”youโ€™ll need it later!