Introduction: Making Forms Reliable and User-Friendly

Welcome back! So far, you have learned how to fetch and display a user’s reading shelf, update progress optimistically, and edit shelf status. Now, let’s focus on making your forms more reliable and user-friendly by adding client-side validation.

Client-side validation helps catch mistakes before data is sent to the server. This means users get instant feedback if they enter something wrong, like leaving a field empty or typing a page number that’s too high. In this lesson, you will learn how to use two popular libraries — Zod and React Hook Form — to add strong, easy-to-understand validation to your forms.

By the end of this lesson, you will know how to:

  • Define validation rules for your forms using Zod.
  • Connect those rules to your forms with React Hook Form.
  • Show clear error messages to users when something is wrong.

Let’s get started!

How React Hook Form Works

The central tool in React Hook Form is the useForm hook. It sets up your form state and provides several helper functions:

  • register: connects each input field to the form state. It simplifies form validation by allowing you to define validation rules directly within the register call, based on HTML standard validation attributes (e.g., required, minLength, maxLength, pattern).
  • handleSubmit: wraps your submit handler so it only runs if validation passes.
  • formState.errors: stores validation errors for each field.
  • reset, setError, and others provide utilities for resetting values or adding custom errors.

Here’s the simplest possible usage:

// basic usage of useForm
import { useForm } from "react-hook-form";

export default function SimpleForm() {
  const { register, handleSubmit, formState: { errors } } = useForm();

  return (
    <form onSubmit={handleSubmit((data) => console.log(data))}>
      <input {...register("username")} placeholder="Username" />
      {errors.username && <p>{errors.username.message}</p>}
      <button>Submit</button>
    </form>
  );
}

In this snippet:

  • register("username") wires the input into the form’s state system.
  • handleSubmit ensures your onSubmit handler runs with validated data.
  • If validation fails, the error message for username will appear.

This pattern keeps form code declarative and reduces boilerplate compared to manually managing useState for each input.

Deep Dive: useForm, register, handleSubmit, and formState.errors

React Hook Form revolves around a few central concepts. While the basics are straightforward, understanding the advanced behavior of these tools helps you build scalable, complex forms without unnecessary re-renders or boilerplate.

1. useForm
The useForm hook is the backbone of React Hook Form. It creates and manages the entire lifecycle of your form: tracking values, handling validation, and exposing utilities.

Key options you can configure when calling useForm include:

  • defaultValues: set initial values for your form fields.
  • mode: determines when validation runs (e.g., "onChange", "onBlur", "onSubmit").
  • resolver: allows you to plug in external validation libraries like Zod or Yup.
  • reValidateMode: controls when a field should re-run validation after the first failure.

This makes useForm highly customizable depending on whether you want instant feedback or only final validation on submit.

const { register, handleSubmit, formState: { errors }, reset, watch } = useForm({
  defaultValues: { username: "", age: 18 },
  mode: "onBlur",
  reValidateMode: "onChange"
});

In this example:

  • Fields start with predefined values ("" and 18).
  • Validation is first triggered when the field loses focus (onBlur).
  • If the user fixes an error, re-validation occurs on each change (onChange).

2. register
The register function binds inputs to the form state. It not only tracks values but can also apply inline validation rules. Inline rules are great for simple cases like required, minLength, maxLength, or pattern. You can also combine register with external schema validation for more advanced control.

<input 
  {...register("password", { 
    required: "Password is required", 
    minLength: { value: 8, message: "Must be at least 8 characters" } 
  })} 
  type="password" 
/>

Here, React Hook Form automatically enforces a minimum length and displays the message if validation fails.

Advanced usage: register accepts options like valueAsNumber or setValueAs, which convert the input value before it is stored in the form state:

<input 
  {...register("age", { 
    valueAsNumber: true, 
    min: { value: 18, message: "Must be at least 18" } 
  })} 
  type="number" 
/>

In this example, the input string "23" is automatically converted to the number 23 in the form state.

3. handleSubmit
The handleSubmit function wraps your form’s submit logic. Its job is to validate the form before executing your handler. It accepts two callbacks: one for successful validation, and an optional one for failed validation.

const onValid = (data) => console.log("Valid data:", data);
const onInvalid = (errors) => console.error("Validation errors:", errors);

<form onSubmit={handleSubmit(onValid, onInvalid)}>
  <input {...register("email", { required: "Email is required" })} />
  <button>Submit</button>
</form>
  • If all validations pass, onValid is called with the form data.
  • If validations fail, onInvalid is called with the full error object.

An advanced tip: you can integrate async operations directly into handleSubmit. For example, you may send data to an API only if the validation passes:

const onValid = async (data) => {
  await fetch("/api/submit", {
    method: "POST",
    body: JSON.stringify(data)
  });
};

This pattern ensures your form data is both validated and safely submitted, while errors are intercepted and handled gracefully.

4. formState.errors
Finally, all validation results are stored in formState.errors. Each field has its own key, making it easy to display contextual error messages. You can safely chain into messages using optional chaining (?.) to avoid crashes when a field has no error.

Together, these four concepts — useForm, register, handleSubmit, and formState.errors — create a powerful, declarative, and scalable form management system.

What is Zod and Why Use It?

Zod is a TypeScript-first schema validation library. Instead of scattering validation logic across your app, Zod lets you define a single schema that describes valid input. A schema is a blueprint: it says “this field must be a number,” “this string must have at least 6 characters,” and so on.

Why Zod is preferred:

  • Type inference: the TypeScript types for your form are generated automatically from the schema (z.infer<typeof schema>).
  • Clear error messages: each validation rule can specify a custom message.
  • Composability: schemas can be nested, refined, or reused across forms.
  • Consistency: one source of truth for what valid data looks like, used on both client and server if needed.

Here’s a simple schema:

// basic Zod schema
import { z } from "zod";

const schema = z.object({
  currentPage: z.number()
    .int("Page must be an integer")
    .min(0, "Page cannot be negative"),
});

In this schema:

  • currentPage must be a number.
  • It must be an integer (no decimals).
  • It cannot be negative.

If a user enters a value that doesn’t match these rules, Zod will return an error message. This makes it easy to keep your data clean and your users informed.

Connecting Zod and React Hook Form

To connect Zod schemas to React Hook Form, we use the zodResolver helper. This adapter allows useForm to run Zod validation whenever the form is submitted or inputs change.

Here’s how you connect them:

// combining useForm with Zod
import { useForm } from "react-hook-form";
import { z } from "zod";
import { zodResolver } from "@hookform/resolvers/zod";

const schema = z.object({
  currentPage: z.number().min(0, "Page cannot be negative"),
});
type FormData = z.infer<typeof schema>;

export default function ValidatedForm() {
  const { register, handleSubmit, formState: { errors } } = useForm<FormData>({
    resolver: zodResolver(schema),
  });

  return (
    <form onSubmit={handleSubmit(console.log)}>
      <input {...register("currentPage")} type="number" />
      {errors.currentPage && <p>{errors.currentPage.message}</p>}
      <button>Save</button>
    </form>
  );
}

Here’s what happens:

  • resolver: zodResolver(schema) tells React Hook Form to validate with Zod.
  • If the data doesn’t match the schema, the errors object will contain the relevant messages.
  • Your submit handler will only run if all validations pass.

This setup keeps all rules in one place and enforces them consistently.

Showing Errors with a Reusable Component

Instead of sprinkling error <p> tags across the app, we use a reusable FormError component to show messages in a consistent style.

// src/components/FormError.tsx
export default function FormError({ message }: { message?: string }) {
  if (!message) return null;
  return (
    <p className="text-red-400 bg-red-900/50 p-2 rounded-md mt-1" role="alert">
      {message}
    </p>
  );
}
  • If a message prop is provided, it renders in a styled <p> tag.
  • If no message is passed, it returns null (nothing is displayed).
  • This keeps form layouts clean and avoids repeating markup.

You can use this component under any form field to display validation errors. For example:

<FormError message={errors.currentPage?.message} />

This will render the error for the currentPage field when validation fails.

Full Example: Validating ProgressEditor

Let’s put it all together in the ProgressEditor component. This combines optimistic updates with Zod validation so users can’t enter nonsense values like negative pages or pages beyond the book’s total.

// src/features/shelf/ProgressEditor.tsx (excerpt)
const schema = z.object({
  currentPage: z.coerce.number()
    .int("Page must be an integer")
    .min(0, "Page cannot be negative")
    .refine(
      (v, ctx) => {
        const max = (ctx as any).parent?.totalPages;
        return typeof max === "number" ? v <= max : true;
      },
      { message: "Page cannot exceed total pages" }
    ),
});
type FormData = z.infer<typeof schema>;

export default function ProgressEditor({ item, totalPages }: ProgressEditorProps) {
  const { register, handleSubmit, formState: { errors }, reset } = useForm<FormData>({
    resolver: zodResolver(schema),
    defaultValues: { currentPage: item.currentPage },
    context: { totalPages },
  });

  return (
    <form onSubmit={handleSubmit((data) => {/* mutation call here */})}>
      <input {...register("currentPage")} type="number" aria-label="Current page" />
      <button>Update</button>
      <FormError message={errors.currentPage?.message} />
    </form>
  );
}

Let’s break down what’s happening:

  • The schema uses z.coerce.number() so even string inputs like "12" are converted to numbers.
  • It enforces integer values, disallows negatives, and uses refine to check against the book’s total pages.
  • React Hook Form is configured with zodResolver(schema) and context: { totalPages } so the validation has access to book-specific data.
  • If a user enters an invalid value, errors.currentPage?.message provides the error text to the FormError component.

This guarantees that only valid page numbers reach the backend mutation.

Summary and Practice Preview

In this lesson, you learned:

  • How useForm manages input state, submission, and errors.
  • What Zod is, how to define schemas with it, and why it’s preferred for clear, type-safe validation.
  • How zodResolver connects Zod and React Hook Form.
  • How to display friendly messages with a reusable FormError component.

By combining React Hook Form and Zod, your forms become easier to maintain, safer against invalid input, and friendlier for users. Next, you’ll practice adding validation rules to your own forms and refining error messages to guide users effectively.

Sign up
Join the 1M+ learners on CodeSignal
Be a part of our community of 1M+ users who develop and demonstrate their skills on CodeSignal