Skip to main content
Ganesh Joshi
Back to Blogs

React Hook Form with Next.js: performant forms

February 15, 20265 min read
Tutorials
Form or React code on screen

Forms are everywhere in web applications. React Hook Form provides a performant, flexible way to build forms with minimal re-renders. Combined with Next.js Server Actions and Zod validation, it creates a great developer experience for forms of any complexity.

Why React Hook Form?

React Hook Form solves common form problems:

Problem React Hook Form solution
Re-renders on every keystroke Uses refs, not state
Boilerplate for each field register() handles inputs
Complex validation logic Resolver pattern with Zod
Dynamic fields useFieldArray hook
Form state management Built-in formState object

Installation

Install React Hook Form and optional validation resolver:

npm install react-hook-form @hookform/resolvers zod

Basic setup

Create a form with useForm:

'use client';

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

interface FormData {
  name: string;
  email: string;
  message: string;
}

export function ContactForm() {
  const {
    register,
    handleSubmit,
    formState: { errors, isSubmitting },
  } = useForm<FormData>();

  async function onSubmit(data: FormData) {
    console.log(data);
    // Submit to server
  }

  return (
    <form onSubmit={handleSubmit(onSubmit)}>
      <input {...register('name', { required: 'Name is required' })} />
      {errors.name && <p>{errors.name.message}</p>}

      <input {...register('email', { required: 'Email is required' })} />
      {errors.email && <p>{errors.email.message}</p>}

      <textarea {...register('message')} />

      <button type="submit" disabled={isSubmitting}>
        {isSubmitting ? 'Sending...' : 'Send'}
      </button>
    </form>
  );
}

Zod validation

Use Zod for type-safe schema validation:

'use client';

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

const schema = z.object({
  name: z.string().min(1, 'Name is required'),
  email: z.string().email('Invalid email address'),
  message: z.string().min(10, 'Message must be at least 10 characters'),
});

type FormData = z.infer<typeof schema>;

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

  // ...
}

The resolver validates all fields and returns typed errors.

With Server Actions

Combine client validation with Server Actions:

'use client';

import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { submitContact } from './actions';
import { contactSchema, type ContactFormData } from './schema';

export function ContactForm() {
  const {
    register,
    handleSubmit,
    setError,
    reset,
    formState: { errors, isSubmitting },
  } = useForm<ContactFormData>({
    resolver: zodResolver(contactSchema),
  });

  async function onSubmit(data: ContactFormData) {
    const result = await submitContact(data);

    if (result.error) {
      // Handle server errors
      if (result.fieldErrors) {
        Object.entries(result.fieldErrors).forEach(([field, messages]) => {
          setError(field as keyof ContactFormData, {
            message: messages[0],
          });
        });
      } else {
        setError('root', { message: result.error });
      }
      return;
    }

    reset();
    // Show success message
  }

  return (
    <form onSubmit={handleSubmit(onSubmit)}>
      {/* form fields */}
    </form>
  );
}

Server action:

'use server';

import { contactSchema } from './schema';

export async function submitContact(data: ContactFormData) {
  const result = contactSchema.safeParse(data);

  if (!result.success) {
    return {
      error: 'Validation failed',
      fieldErrors: result.error.flatten().fieldErrors,
    };
  }

  await db.insert(contacts).values(result.data);
  revalidatePath('/contact');

  return { success: true };
}

Form state

Access form state for UI feedback:

const {
  formState: {
    errors,        // Field errors
    isSubmitting,  // Form is being submitted
    isValid,       // All fields valid
    isDirty,       // Any field changed
    dirtyFields,   // Which fields changed
    touchedFields, // Which fields were focused
  },
} = useForm<FormData>();

Validation modes

Control when validation runs:

Mode Validation timing
onSubmit Only on form submit (default)
onBlur When field loses focus
onChange On every change (performance cost)
onTouched On blur for touched fields, then onChange
all On blur and onChange
useForm<FormData>({
  resolver: zodResolver(schema),
  mode: 'onTouched', // Show errors after first blur
});

Default values

Pre-populate forms:

useForm<FormData>({
  defaultValues: {
    name: '',
    email: user?.email ?? '',
    plan: 'free',
  },
});

For async defaults:

useForm<FormData>({
  defaultValues: async () => {
    const user = await fetchUser();
    return {
      name: user.name,
      email: user.email,
    };
  },
});

Controller for custom components

Use Controller for controlled components like select libraries:

import { Controller } from 'react-hook-form';
import { Select } from '@/components/ui';

<Controller
  name="category"
  control={control}
  render={({ field, fieldState }) => (
    <Select
      {...field}
      options={categoryOptions}
      error={fieldState.error?.message}
    />
  )}
/>

Dynamic fields with useFieldArray

Handle arrays of fields:

import { useFieldArray } from 'react-hook-form';

function DynamicForm() {
  const { control, register } = useForm({
    defaultValues: {
      items: [{ name: '' }],
    },
  });

  const { fields, append, remove } = useFieldArray({
    control,
    name: 'items',
  });

  return (
    <form>
      {fields.map((field, index) => (
        <div key={field.id}>
          <input {...register(`items.${index}.name`)} />
          <button type="button" onClick={() => remove(index)}>
            Remove
          </button>
        </div>
      ))}
      <button type="button" onClick={() => append({ name: '' })}>
        Add Item
      </button>
    </form>
  );
}

Watch values

React to field changes:

const { watch } = useForm<FormData>();

// Watch single field
const email = watch('email');

// Watch multiple fields
const [name, email] = watch(['name', 'email']);

// Watch all fields
const allValues = watch();

For performance, use watch sparingly—it triggers re-renders.

Reset and clear

Reset form to defaults or clear values:

const { reset, resetField } = useForm<FormData>();

// Reset entire form
reset();

// Reset with new values
reset({ name: 'New Name', email: 'new@email.com' });

// Reset single field
resetField('email');

Error handling patterns

Display errors consistently:

function FormField({
  name,
  label,
  register,
  errors,
  ...props
}: FormFieldProps) {
  const error = errors[name];

  return (
    <div className="space-y-1">
      <label htmlFor={name}>{label}</label>
      <input
        id={name}
        {...register(name)}
        {...props}
        className={error ? 'border-red-500' : 'border-gray-300'}
      />
      {error && (
        <p className="text-sm text-red-500">{error.message}</p>
      )}
    </div>
  );
}

File uploads

Handle file inputs:

const { register, handleSubmit } = useForm<{ file: FileList }>();

async function onSubmit(data: { file: FileList }) {
  const formData = new FormData();
  formData.append('file', data.file[0]);
  await uploadFile(formData);
}

<input type="file" {...register('file')} accept="image/*" />

Performance tips

Tip Implementation
Avoid watching all fields Watch specific fields only
Use mode: 'onTouched' Balance UX and performance
Memoize handlers useCallback for callbacks
Split large forms Multiple smaller forms or steps
DevTools for debugging @hookform/devtools package

DevTools

Debug form state visually:

npm install -D @hookform/devtools
import { DevTool } from '@hookform/devtools';

function Form() {
  const { control } = useForm();

  return (
    <>
      <form>{/* fields */}</form>
      <DevTool control={control} />
    </>
  );
}

Summary

React Hook Form minimizes re-renders by using refs instead of state. Use Zod for type-safe validation with the resolver pattern. Integrate with Server Actions by calling them from handleSubmit. Use Controller for custom components and useFieldArray for dynamic fields. Set validation mode based on UX needs. The result is performant, maintainable form code.

Frequently Asked Questions

React Hook Form is a library for building performant forms in React. It uses uncontrolled inputs and refs to minimize re-renders, making it ideal for large or complex forms.

Controlled forms re-render on every keystroke. React Hook Form uses refs and uncontrolled inputs, so the component only re-renders when you access form state like errors or watch values.

Yes. Use handleSubmit to validate on the client, then call your Server Action with the validated data. You can also use native form action with React Hook Form for progressive enhancement.

Use the resolver option with a validation library like Zod. The zodResolver parses form data against your schema and returns typed errors that React Hook Form displays automatically.

Use React Hook Form for complex forms with many fields, dynamic fields, or when you need fine-grained control. Use native forms with useFormStatus for simple forms where you want minimal JavaScript.

Related Posts