Lesson 5

Form State and Errors

React Hook Form tracks everything about your form's lifecycle — which fields have been touched, whether anything changed, whether validation passed, and how many times the user tried to submit. All of this lives in the formState object.

Accessing formState

The formState object is returned from useForm alongside register and handleSubmit. You destructure the specific properties you need:

const {
  register,
  handleSubmit,
  formState: {
    errors,
    isDirty,
    isValid,
    isSubmitting,
    dirtyFields,
    touchedFields,
    submitCount,
  },
} = useForm();

Each of these properties tells you something different about the form's current state.

errors

The errors object is what you'll reach for most often. It contains validation errors keyed by field name. Each entry has a message property (among others) that holds the error string you defined in your validation rules.

<input {...register("email", { required: "Email is required" })} />
{errors.email && <p style={{ color: "red" }}>{errors.email.message}</p>}

If the user submits without filling in the email field, errors.email will be populated and the error message renders inline. Once they fill it in and validation passes, errors.email becomes undefined and the message disappears.

For nested access, use optional chaining. The field might not have an error at all, so errors.fieldName?.message is the safe pattern:

<input
  {...register("password", {
    required: "Password is required",
    minLength: { value: 8, message: "Must be at least 8 characters" },
  })}
/>
{errors.password && (
  <p style={{ color: "red" }}>{errors.password.message}</p>
)}

When multiple validation rules exist on a field, RHF reports the first one that fails. Fix that error, and the next rule is evaluated on the next validation pass.

isSubmitting

isSubmitting is true while your onSubmit handler is running. If your handler is async — making an API call, for example — isSubmitting stays true until the promise resolves. This gives you a clean way to disable the submit button and show a loading indicator:

<button type="submit" disabled={isSubmitting}>
  {isSubmitting ? "Submitting..." : "Submit"}
</button>

This prevents double submissions without any extra state management on your part.

isDirty

isDirty is true when any field's current value differs from its defaultValues. This is a deep comparison — RHF checks every registered field against what it started with.

const { formState: { isDirty } } = useForm({
  defaultValues: {
    name: "",
    email: "",
  },
});

A common use case is warning users about unsaved changes before they navigate away:

useEffect(() => {
  const handleBeforeUnload = (e: BeforeUnloadEvent) => {
    if (isDirty) {
      e.preventDefault();
    }
  };
  window.addEventListener("beforeunload", handleBeforeUnload);
  return () => window.removeEventListener("beforeunload", handleBeforeUnload);
}, [isDirty]);

If you need to know which specific fields changed, use dirtyFields. It's an object where each key is a field name and the value is true if that field has been modified.

isValid

isValid is true when the form has no validation errors. There is an important catch: by default, RHF only validates on submit. If you want isValid to update as the user types, you need to set the validation mode:

const { formState: { isValid } } = useForm({
  mode: "onChange",
  defaultValues: {
    email: "",
    password: "",
  },
});

With mode: "onChange", validation runs on every change and isValid reflects the current state in real time. You can use this to disable the submit button until the form is valid:

<button type="submit" disabled={!isValid}>
  Submit
</button>

Other mode options include "onBlur" (validates when a field loses focus) and "onTouched" (validates on blur the first time, then on change after that). Pick the one that fits your UX.

touchedFields and submitCount

touchedFields tracks which fields the user has interacted with. A field becomes "touched" once it receives and then loses focus (blur). This is useful for showing errors only after the user has actually visited a field:

{touchedFields.email && errors.email && (
  <p style={{ color: "red" }}>{errors.email.message}</p>
)}

submitCount is a number that increments every time the user clicks submit, whether or not validation passes. You can use it to change your UI after the first submission attempt — for example, showing all errors at once after the user has tried to submit at least once.

The Proxy Optimization

Here is something important about formState that sets RHF apart: it is a Proxy object. RHF only tracks and triggers re-renders for the properties you actually read from it.

If you destructure errors and nothing else, your component only re-renders when errors change. If you also destructure isDirty, you'll get re-renders when dirty state changes too. But if you never read isValid, changes to validation state won't cause a re-render.

This is intentional. RHF uses the Proxy to detect which formState properties your component accesses, then subscribes to only those. It means you get fine-grained reactivity without doing anything special — just destructure what you need and ignore the rest.

The practical implication: don't spread the entire formState into a child component or log it in a useEffect dependency array. Access only the properties you need where you need them.

Complete Example

Here's a registration form that ties everything together — inline errors, a disabled submit button during submission, and a dirty state indicator:

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

type FormData = {
  name: string;
  email: string;
  password: string;
};

function RegistrationForm() {
  const {
    register,
    handleSubmit,
    formState: { errors, isSubmitting, isDirty, isValid },
  } = useForm<FormData>({
    mode: "onTouched",
    defaultValues: {
      name: "",
      email: "",
      password: "",
    },
  });

  const onSubmit = async (data: FormData) => {
    await new Promise((resolve) => setTimeout(resolve, 2000));
    console.log("Submitted:", data);
  };

  return (
    <form onSubmit={handleSubmit(onSubmit)}>
      {isDirty && (
        <p style={{ color: "orange" }}>You have unsaved changes</p>
      )}

      <div>
        <label htmlFor="name">Name</label>
        <input
          id="name"
          {...register("name", { required: "Name is required" })}
        />
        {errors.name && (
          <p style={{ color: "red" }}>{errors.name.message}</p>
        )}
      </div>

      <div>
        <label htmlFor="email">Email</label>
        <input
          id="email"
          type="email"
          {...register("email", {
            required: "Email is required",
            pattern: {
              value: /^[^\s@]+@[^\s@]+\.[^\s@]+$/,
              message: "Enter a valid email address",
            },
          })}
        />
        {errors.email && (
          <p style={{ color: "red" }}>{errors.email.message}</p>
        )}
      </div>

      <div>
        <label htmlFor="password">Password</label>
        <input
          id="password"
          type="password"
          {...register("password", {
            required: "Password is required",
            minLength: {
              value: 8,
              message: "Password must be at least 8 characters",
            },
          })}
        />
        {errors.password && (
          <p style={{ color: "red" }}>{errors.password.message}</p>
        )}
      </div>

      <button type="submit" disabled={isSubmitting}>
        {isSubmitting ? "Submitting..." : "Create Account"}
      </button>
    </form>
  );
}

export default RegistrationForm;

This form uses mode: "onTouched" so errors appear after the user leaves a field, not while they're still typing. The isDirty flag drives an unsaved changes banner at the top. Each field renders its own error message inline. The submit button disables and shows loading text while the async handler runs.

Because we only destructured errors, isSubmitting, isDirty, and isValid from formState, those are the only state changes that trigger re-renders. The Proxy handles the rest — the component stays fast even as the form grows.