react-hook-form-image

Clean, Type-Safe Forms with React-Hook-Form and Zod

Nicholas Cathcart

Writing forms has always been a chore for me. Managing regex, implementing validation, and handling validation error states can be quite the pain. Recently, I decided to look for a better way. Here is where I landed: react-hook-form and zod.

Let's explore this with a simple example: a mailing list signup form that requires a first name, last name, email, and phone number. Typically, using just TypeScript and React, this might look something like:

import React, { useState, ChangeEvent, FormEvent } from 'react';

interface FormData {
  firstName: string;
  lastName: string;
  email: string;
  phone: string;
}

interface FormErrors {
  firstName?: string;
  lastName?: string;
  email?: string;
  phone?: string;
}

function Form() {
  const [formData, setFormData] = useState<FormData>({
    firstName: '',
    lastName: '',
    email: '',
    phone: ''
  });

  const [errors, setErrors] = useState<FormErrors>({});

  const handleChange = (e: ChangeEvent<HTMLInputElement>): void => {
    const { name, value } = e.target;
    setFormData({
      ...formData,
      [name]: value
    });
  };

  const validate = (): FormErrors => {
    let formErrors: FormErrors = {};
    if (!formData.firstName.trim()) formErrors.firstName = 'First Name is required';
    if (!formData.lastName.trim()) formErrors.lastName = 'Last Name is required';
    if (!formData.email) {
      formErrors.email = 'Email is required';
    } else if (!/\S+@\S+\.\S+/.test(formData.email)) {
      formErrors.email = 'Email address is invalid';
    }
    if (!formData.phone) {
      formErrors.phone = 'Phone number is required';
    } else if (!/^\d{10}$/.test(formData.phone)) {
      formErrors.phone = 'Phone number is invalid, must be 10 digits';
    }
    return formErrors;
  };

  const handleSubmit = (e: FormEvent<HTMLFormElement>): void => {
    e.preventDefault();
    const validationErrors = validate();
    if (Object.keys(validationErrors).length > 0) {
      setErrors(validationErrors);
    } else {
      setErrors({});
      // Submit form data
      console.log('Form submitted successfully', formData);
    }
  };

  return (
    <form onSubmit={handleSubmit}>
      <div>
        <label>First Name:</label>
        <input
          type="text"
          name="firstName"
          value={formData.firstName}
          onChange={handleChange}
        />
        {errors.firstName && <span style={{ color: 'red' }}>{errors.firstName}</span>}
      </div>
      <div>
        <label>Last Name:</label>
        <input
          type="text"
          name="lastName"
          value={formData.lastName}
          onChange={handleChange}
        />
        {errors.lastName && <span style={{ color: 'red' }}>{errors.lastName}</span>}
      </div>
      <div>
        <label>Email:</label>
        <input
          type="email"
          name="email"
          value={formData.email}
          onChange={handleChange}
        />
        {errors.email && <span style={{ color: 'red' }}>{errors.email}</span>}
      </div>
      <div>
        <label>Phone Number:</label>
        <input
          type="text"
          name="phone"
          value={formData.phone}
          onChange={handleChange}
        />
        {errors.phone && <span style={{ color: 'red' }}>{errors.phone}</span>}
      </div>
      <button type="submit">Submit</button>
    </form>
  );
}

export default Form;

Blerg. Gross. This method is functional, but it's far from elegant. To enhance it, we could encapsulate our state logic into custom hooks, but that raises questions about where to store these hooks and how to name them, how general they should be, etc. It's a cycle of complexity. Enter react-hook-form and zod. Here's how we can rewrite the same form with our new tools:

import React from 'react';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import * as z from 'zod';

const schema = z.object({
  firstName: z.string().min(1, { message: 'First Name is required' }),
  lastName: z.string().min(1, { message: 'Last Name is required' }),
  email: z.string().email({ message: 'Email address is invalid' }),
  phone: z.string().regex(/^\d{10}$/, { message: 'Phone number must be 10 digits' }),
});

type FormData = z.infer<typeof schema>;

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

  const onSubmit = async (data: FormData) => {
    // Simulate a form submission
    await new Promise((resolve) => setTimeout(resolve, 2000));
    console.log('Form submitted successfully', data);
  };

  return (
    <form onSubmit={handleSubmit(onSubmit)}>
      <div>
        <label>First Name:</label>
        <input {...register('firstName')} />
        {errors.firstName && <span style={{ color: 'red' }}>{errors.firstName.message}</span>}
      </div>
      <div>
        <label>Last Name:</label>
        <input {...register('lastName')} />
        {errors.lastName && <span style={{ color: 'red' }}>{errors.lastName.message}</span>}
      </div>
      <div>
        <label>Email:</label>
        <input {...register('email')} />
        {errors.email && <span style={{ color: 'red' }}>{errors.email.message}</span>}
      </div>
      <div>
        <label>Phone Number:</label>
        <input {...register('phone')} />
        {errors.phone && <span style={{ color: 'red' }}>{errors.phone.message}</span>}
      </div>
      <button type="submit" disabled={isSubmitting}>
        {isSubmitting ? 'Submitting...' : 'Submit'}
      </button>
      {isSubmitSuccessful && <div>Form submitted successfully!</div>}
    </form>
  );
}

Ahhhh. Much Better.

Key Improvements:

Schema Definition: We start by defining our schema using z.object(), which allows us to clearly specify validation rules and messages.

Type Safety: With z.infer, the form data type is automatically inferred, ensuring type safety without extra effort.

Streamlined Handling: The useForm hook simplifies registration, submission, and error handling, enhancing both developer experience and performance.

Feedback Management: State management for form submission and feedback is seamlessly handled, providing a smooth user experience.

Considerations:

While react-hook-form favors uncontrolled components, which may complicate scenarios requiring controlled components, it remains highly manageable. The comprehensive documentation further eases implementation. Although zod is robust and versatile, it may take a while to find what you are looking for—perseverance will be rewarded.

Conclusion:

Adopting react-hook-form and zod has not only streamlined our form handling but also STANDARDIZED our approach. This consistency means that when I, or anyone in my team, encounter a form someone else has implemented, we can understand and manage it immediately. If you're seeking to elevate your form implementations, I highly recommend these tools.

Now, go forth and build forms that don't suck. ;)

Cheers,

NHC