React Hook Form vs. React 19: Should you still use RHF in 2025?

Written by Vijit Ail✏️ Forms are an essential part of how users interact with websites and web applications. Validating a user's data passed through a form is a crucial responsibility for a developer. React Hook Form is a library that helps validate forms in React. It is a minimal library without any other dependencies, and is performant and straightforward to use, requiring developers to write fewer lines of code than other form libraries. React 19 introduces built-in form handling. So, you might be asking: Is React Hook Form still worth using? In this guide, you will learn the differences, advantages, and best use cases of React Hook Form in 2025. What is React Hook Form? React Hook Form takes a slightly different approach than other form libraries in the React ecosystem by using uncontrolled inputs with ref instead of depending on the state to control the inputs. This approach makes the forms more performant and reduces the number of re-renders. This also means that React Hook Form offers seamless integration with UI libraries because most libraries support the ref attribute. React Hook Form’s size is very small (just 8.6 kB minified and gzipped), and it has zero dependencies. The API is also very intuitive, which provides a seamless experience to developers. The library follows HTML standards for validating the forms using a constraint-based validation API. To install React Hook Form, run the following command: npm install react-hook-form Editor’s note: This article was last updated by Isaac Okoro in April 2025 to reflect React 19’s new form-handling features, as well as to compare React Hook Form with React 19’s built-in form handling, explaining the differences and reinforcing when RHS is still the best option. How to use React Hooks in a form In this section, you will learn about the fundamentals of the useForm Hook by creating a very basic registration form. First, import the useForm Hook from the react-hook-form package: import { useForm } from "react-hook-form"; Then, inside your component, use the Hook as follows: const { register, handleSubmit } = useForm(); The useForm Hook returns an object containing a few properties. For now, we’ll only require register and handleSubmit. The register method helps you register an input field into React Hook Form so that it is available for validation, and its value can be tracked for changes. To register the input, we’ll pass the register method into the input field as such: This spread operator syntax is a new implementation in the library that enables strict type checking in forms with TypeScript. You can learn more about strict type checking in React Hook Form here. React Hook Form versions older than v7 had the register method attached to the ref attribute as such: Note that the input component must have a name prop, and its value should be unique. The handleSubmit method, as the name suggests, manages form submission. It needs to be passed as the value to the onSubmit prop of the form component. The handleSubmit method can handle two functions as arguments. The first function passed as an argument will be invoked along with the registered field values when the form validation is successful. The second function is called with errors when the validation fails: const onFormSubmit = data => console.log(data); const onErrors = errors => console.error(errors); {/* ... */} Now that you have a fair idea about the basic usage of the useForm Hook, let’s look at a more realistic example: import React from "react"; import { useForm } from "react-hook-form"; const RegisterForm = () => { const { register, handleSubmit } = useForm(); const handleRegistration = (data) => console.log(data); return ( Name Email Password Submit ); }; export default RegisterForm; As you can see, no other components were imported to track the input values. The useForm Hook makes the component code cleaner and easier to maintain, and because the form is uncontrolled, you do not have to pass props like onChange and value to each input. You can use any other UI library of your choice to create the form. But first, make sure to check the documentation and find the prop used for accessing the reference attribute of the native input component. In the next section, you will learn how to handle form validation in the form you just built. How to validate forms with React Hook Form To apply validations to a field, you can pass validation parameters to the register method. Validation parameters are similar to the existing HTML form validation standard. These validation parameters include the following properties: required indicates if the field is required or not. If this property is set to true, then the field cannot be empty minlength and maxlength set the m

Apr 30, 2025 - 15:22
 0
React Hook Form vs. React 19: Should you still use RHF in 2025?

Written by Vijit Ail✏️

Forms are an essential part of how users interact with websites and web applications. Validating a user's data passed through a form is a crucial responsibility for a developer. React Hook Form is a library that helps validate forms in React. It is a minimal library without any other dependencies, and is performant and straightforward to use, requiring developers to write fewer lines of code than other form libraries.

React 19 introduces built-in form handling. So, you might be asking: Is React Hook Form still worth using? In this guide, you will learn the differences, advantages, and best use cases of React Hook Form in 2025.

What is React Hook Form?

React Hook Form takes a slightly different approach than other form libraries in the React ecosystem by using uncontrolled inputs with ref instead of depending on the state to control the inputs. This approach makes the forms more performant and reduces the number of re-renders.

This also means that React Hook Form offers seamless integration with UI libraries because most libraries support the ref attribute. React Hook Form’s size is very small (just 8.6 kB minified and gzipped), and it has zero dependencies.

The API is also very intuitive, which provides a seamless experience to developers. The library follows HTML standards for validating the forms using a constraint-based validation API. To install React Hook Form, run the following command:

npm install react-hook-form

Editor’s note: This article was last updated by Isaac Okoro in April 2025 to reflect React 19’s new form-handling features, as well as to compare React Hook Form with React 19’s built-in form handling, explaining the differences and reinforcing when RHS is still the best option.

How to use React Hooks in a form

In this section, you will learn about the fundamentals of the useForm Hook by creating a very basic registration form. First, import the useForm Hook from the react-hook-form package:

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

Then, inside your component, use the Hook as follows:

const { register, handleSubmit } = useForm();

The useForm Hook returns an object containing a few properties. For now, we’ll only require register and handleSubmit. The register method helps you register an input field into React Hook Form so that it is available for validation, and its value can be tracked for changes. To register the input, we’ll pass the register method into the input field as such:

<input type="text" name="firstName" {...register('firstName')} />

This spread operator syntax is a new implementation in the library that enables strict type checking in forms with TypeScript. You can learn more about strict type checking in React Hook Form here. React Hook Form versions older than v7 had the register method attached to the ref attribute as such:

<input type="text" name="firstName" ref={register} />

Note that the input component must have a name prop, and its value should be unique. The handleSubmit method, as the name suggests, manages form submission. It needs to be passed as the value to the onSubmit prop of the form component. The handleSubmit method can handle two functions as arguments. The first function passed as an argument will be invoked along with the registered field values when the form validation is successful. The second function is called with errors when the validation fails:

const onFormSubmit  = data => console.log(data);

const onErrors = errors => console.error(errors);

<form onSubmit={handleSubmit(onFormSubmit, onErrors)}>
 {/* ... */}
</form>

Now that you have a fair idea about the basic usage of the useForm Hook, let’s look at a more realistic example:

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

const RegisterForm = () => {
  const { register, handleSubmit } = useForm();
  const handleRegistration = (data) => console.log(data);

  return (
    <form onSubmit={handleSubmit(handleRegistration)}>
      <div>
        <label>Name</label>
        <input name="name" {...register('name')} />
      </div>
      <div>
        <label>Email</label>
        <input type="email" name="email" {...register('email')} />
      </div>
      <div>
        <label>Password</label>
        <input type="password" name="password" {...register('password')} />
      </div>
      <button>Submit</button>
    </form>
  );
};
export default RegisterForm;

As you can see, no other components were imported to track the input values. The useForm Hook makes the component code cleaner and easier to maintain, and because the form is uncontrolled, you do not have to pass props like onChange and value to each input.

You can use any other UI library of your choice to create the form. But first, make sure to check the documentation and find the prop used for accessing the reference attribute of the native input component. In the next section, you will learn how to handle form validation in the form you just built.

How to validate forms with React Hook Form

To apply validations to a field, you can pass validation parameters to the register method. Validation parameters are similar to the existing HTML form validation standard. These validation parameters include the following properties:

  • required indicates if the field is required or not. If this property is set to true, then the field cannot be empty
  • minlength and maxlength set the minimum and maximum length for a string input value
  • min and max set the minimum and maximum values for a numerical value
  • type indicates the type of the input field; it can be email, number, text, or any other standard HTML input types
  • pattern defines a pattern for the input value using a regular expression

If you want to mark a field as required, your code should turn out like this:

<input name="name" type="text" {...register('name', { required: true } )} />

Now try submitting the form with this field empty. This will result in the following error object:

{
name: {
  type: "required",
  message: "",
  ref: <input name="name" type="text" />
  }
}

Here, the type property refers to the type of validation that failed, and the ref property contains the native DOM input element. You can also include a custom error message for the field by passing a string instead of a Boolean to the validation property:

// ...
<form onSubmit={handleSubmit(handleRegistration, handleError)}>
  <div>
      <label>Name</label>
      <input name="name" {...register('name', { required: "Name is required" } )} />
  </div>
</form>

Then, access the errors object by using the useForm Hook:

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

You can display errors to your users like so:

const RegisterForm = () => {
  const { register, handleSubmit, formState: { errors } } = useForm();
  const handleRegistration = (data) => console.log(data);

  return (
    <form onSubmit={handleSubmit(handleRegistration)}>
      <div>
        <label>Name</label>
        <input type="text" name="name" {...register('name')} />
        {errors?.name && errors.name.message}
      </div>
      {/* more input fields... */}
      <button>Submit</button>
    </form>
  );
};

Below you can find the complete example:

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

const RegisterForm = () => {
  const { register, handleSubmit, formState: { errors } } = useForm();
  const handleRegistration = (data) => console.log(data);
  const handleError = (errors) => {};

  const registerOptions = {
    name: { required: "Name is required" },
    email: { required: "Email is required" },
    password: {
      required: "Password is required",
      minLength: {
        value: 8,
        message: "Password must have at least 8 characters"
      }
    }
  };

  return (
    <form onSubmit={handleSubmit(handleRegistration, handleError)}>
      <div>
        <label>Name</label>
        <input name="name" type="text" {...register('name', registerOptions.name) }/>
        <small className="text-danger">
          {errors?.name && errors.name.message}
        </small>
      </div>
      <div>
        <label>Email</label>
        <input
          type="email"
          name="email"
          {...register('email', registerOptions.email)}
        />
        <small className="text-danger">
          {errors?.email && errors.email.message}
        </small>
      </div>
      <div>
        <label>Password</label>
        <input
          type="password"
          name="password"
          {...register('password', registerOptions.password)}
        />
        <small className="text-danger">
          {errors?.password && errors.password.message}
        </small>
      </div>
      <button>Submit</button>
    </form>
  );
};
export default RegisterForm;

If you want to validate the field when there is an onChange or onBlur event, you can pass a mode property to the useForm Hook:

const { register, handleSubmit, errors } = useForm({
  mode: "onBlur"
});

You can find more details on the useForm Hook in the API reference.

Usage with third-party components

In some cases, the external UI component you want to use in your form may not support ref, and can only be controlled by the state. React Hook Form has provisions for such cases and can easily integrate with any third-party-controlled components using a Controller component.

React Hook Form provides the wrapper Controller component that allows you to register a controlled external component, similar to how the register method works. In this case, instead of the register method, you will use the control object from the useForm Hook:

const { register, handleSubmit, control } = useForm();

Say that you have to create a role field in your form that will accept values from a select input. You can create the select input using the react-select library. The control object should be passed to the control prop of the Controller component, along with the name of the field.

You can specify the validation rules using the rules prop. The controlled component should be passed to the Controller component using the as prop. The Select component also requires an options prop to render the dropdown options:

<Controller
  name="role"
  control={control}
  defaultValue=""
  rules={registerOptions.role}
  render={({ field }) => (
    <Select options={selectOptions} {...field} label="Text field" />
  )}
/>

The render prop above provides onChange, onBlur, name, ref, and value to the child component. By spreading field into the Select component, React Hook Form registers the input field. You can check out the complete example for the role field below:

import { useForm, Controller } from "react-hook-form";
import Select from "react-select";
// ...
const { register, handleSubmit, errors, control } = useForm({
  // use mode to specify the event that triggers each input field 
  mode: "onBlur"
});

const selectOptions = [
  { value: "student", label: "Student" },
  { value: "developer", label: "Developer" },
  { value: "manager", label: "Manager" }
];

const registerOptions = {
  // ...
  role: { required: "Role is required" }
};

// ...
<form>
  <div>
    <label>Your Role</label>
    <Controller
      name="role"
      control={control}
      defaultValue=""
      rules={registerOptions.role}
      render={({ field }) => (
        <Select options={selectOptions} {...field} label="Text field" />
      )}
    />
    <small className="text-danger">
      {errors?.role && errors.role.message}
    </small>
  </div>
</form>

You can also go through the API reference for the Controller component for a detailed explanation.

Using useFormContext in React Hook Form

useFormContext is a hook provided by React Hook Form that allows you to access and manipulate the form context/state of deeply nested components.

It allows you to share form methods like register, errors, control, etc., within a component without passing props down through multiple levels. useFormContext is useful when you need to access form methods in deeply nested components or when using custom hooks that need to interact with the form state. Here is how to use useFormContext:

import React from 'react';
import { useForm, FormProvider, useFormContext } from 'react-hook-form';

const Input = ({ name }) => {
  const { register } = useFormContext();
  return <input {...register(name)} />;
};

const ContextForm = () => {
  const methods = useForm();
  return (
    <FormProvider {...methods}>
      <form onSubmit={methods.handleSubmit(data => console.log(data))}>
        <Input name="firstName" />
        <Input name="lastName" />
        <button type="submit">Submit</button>
      </form>
    </FormProvider>
  );
};

export default ContextForm;

In the example above, Input component uses the useFormContext Hook to access the form method register, allowing it to register the input field without prop drilling from the parent component. You can also create a component to make it easier for developers to handle more complex forms, such as when inputs are deeply nested within component trees:

import { FormProvider, useForm, useFormContext } from "react-hook-form";

export const ConnectForm = ({ children }) => {
  const methods = useFormContext();
  return children({ ...methods });
};

export const DeepNest = () => (
  <ConnectForm>
    {({ register }) => <input {...register("hobbies")} />}
  </ConnectForm>
);

export const App = () => {
  const methods = useForm();

  return (
    <FormProvider {...methods}>
      <form>
        <DeepNest />
      </form>
    </FormProvider>
  );
};

Working with arrays and nested fields

React Hook Form supports arrays and nested fields out of the box, allowing you to easily handle complex data structures. To work with arrays, you can use the useFieldArray Hook. This is a custom hook provided by React Hook Form that helps with handling form fields, such as arrays of inputs. The hook provides methods to add, remove, and swap array items. Let’s see the useFieldArray Hook in action:

import React from 'react';
import { useForm, FormProvider, useFieldArray, useFormContext } from 'react-hook-form';

const Hobbies = () => {
  const { control, register } = useFormContext();
  const { fields, append, remove } = useFieldArray({
    control,
    name: 'hobbies'
  });

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

const MyForm = () => {
  const methods = useForm();

  const onSubmit = data => {
    console.log(data);
  };

  return (
    <FormProvider {...methods}>
      <form onSubmit={methods.handleSubmit(onSubmit)}>
        <Hobbies />
        <button type="submit">Submit</button>
      </form>
    </FormProvider>
  );
};

export default MyForm;

From the above code, the Hobbies component uses useFieldArray to manage an array of hobbies. Users can add or remove hobbies dynamically, and each hobby has its own set of fields. You can also opt to control the entire field array to update the field object with each onChange event. You can map the watched field array values to the controlled fields to make sure that input changes reflect on the field object:

import React from 'react';
import { useForm, FormProvider, useFieldArray, useFormContext } from 'react-hook-form';

const Hobbies = () => {
  const { control, register, watch } = useFormContext();
  const { fields, append, remove } = useFieldArray({
    control,
    name: 'hobbies'
  });
  const watchedHobbies = watch("hobbies");
  const controlledFields = fields.map((field, index) => ({
    ...field,
    ...watchedHobbies[index]
  }));

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

const MyForm = () => {
  const methods = useForm({
    defaultValues: {
      hobbies: [{ name: "Reading" }]
    }
  });

  const onSubmit = data => {
    console.log(data);
  };

  return (
    <FormProvider {...methods}>
      <form onSubmit={methods.handleSubmit(onSubmit)}>
        <Hobbies />
        <button type="submit">Submit</button>
      </form>
    </FormProvider>
  );
};

export default MyForm;

The code above uses the watch function to monitor changes to the hobbies field array and controlledFields to make sure that each input reflects its latest state. Nested fields can be handled similarly to arrays. You just need to specify the correct path using dot notation when registering inputs:

import React from 'react';
import { useForm, FormProvider, useFormContext } from 'react-hook-form';

const Address = () => {
  const { register } = useFormContext();
  return (
    <div>
      <input {...register('address.street')} placeholder="Street" />
      <input {...register('address.city')} placeholder="City" />
    </div>
  );
};

const MyForm = () => {
  const methods = useForm();

  const onSubmit = data => {
    console.log(data);
  };

  return (
    <FormProvider {...methods}>
      <form onSubmit={methods.handleSubmit(onSubmit)}>
        <Address />
        <button type="submit">Submit</button>
      </form>
    </FormProvider>
  );
};

export default MyForm;

In the code above, the Address component registers fields for street and city under the address object in the form state. This way, the form data will be structured as an object with nested properties:

{
  "address": {
    "street": "value",
    "city": "value"
  }
}

Using useFormContext with a deeply nested field can affect the performance of your application when it is not managed properly because the FormProvider triggers a re-render whenever the form state updates.

Using a tool like React memo can help optimize performance when using the useFormContext Hook by preventing unnecessary re-renders.

Validation for arrays and nested fields

React Hook Form supports validation for arrays and nested fields using the Yup or Zod validation libraries.

The following example sets up validation for the hobbies array and the address object using Yup schema validation. Each hobby name and address field is validated according to the specified rules:

import React from 'react';
import { useForm, FormProvider, useFieldArray, useFormContext } from 'react-hook-form';
import { yupResolver } from '@hookform/resolvers/yup';
import * as yup from 'yup';

const schema = yup.object().shape({
  hobbies: yup.array().of(
    yup.object().shape({
      name: yup.string().required('Hobby is required')
    })
  ),
  address: yup.object().shape({
    street: yup.string().required('Street is required'),
    city: yup.string().required('City is required')
  })
});

const Hobbies = () => {
  const { control, register } = useFormContext();
  const { fields, append, remove } = useFieldArray({
    control,
    name: 'hobbies'
  });

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

const Address = () => {
  const { register } = useFormContext();
  return (
    <div>
      <input {...register('address.street')} placeholder="Street" />
      <input {...register('address.city')} placeholder="City" />
    </div>
  );
};

const App = () => {
  const methods = useForm({
    resolver: yupResolver(schema)
  });

  const onSubmit = data => {
    console.log(data);
  };

  return (
    <FormProvider {...methods}>
      <form onSubmit={methods.handleSubmit(onSubmit)}>
        <Hobbies />
        <Address />
        <button type="submit">Submit</button>
      </form>
    </FormProvider>
  );
};

export default App;

Handling forms with React 19

One of the most exciting upgrades I have witnessed in React 19 is its complete overhaul of form handling. If you've been building React apps for a while, you're probably familiar with the fact that you have to control inputs, manage state, and then handle submission. React 19 changes all that with a better approach that gets back to the web fundamentals.

The way we traditionally handled React Forms before React 19

In React 18, we had to manually:

  • Track each input's state with useState
  • Handle changes with custom functions
  • Prevent default form behavior
  • Manage loading states
  • Reset forms after submission

For example, a simple form in React 18 looked something like this:

function ContactForm() {
  const [email, setEmail] = useState('');
  const [name, setName] = useState('');
  const [isSubmitting, setIsSubmitting] = useState(false);

  const handleSubmit = async (e) => {
    e.preventDefault();
    setIsSubmitting(true);
    try {
      await submitFormData({ email, name });
      setEmail('');
      setName('');
    } catch (error) {
      console.error(error);
    } finally {
      setIsSubmitting(false);
    }
  };

  return (
    <form onSubmit={handleSubmit}>
      <input 
        type="text" 
        value={name} 
        onChange={(e) => setName(e.target.value)} 
      />
      <input 
        type="email" 
        value={email} 
        onChange={(e) => setEmail(e.target.value)} 
      />
      <button type="submit" disabled={isSubmitting}>
        {isSubmitting ? 'Submitting...' : 'Submit'}
      </button>
    </form>
  );
}

This approach always felt a bit weird — maybe not to React devs but certainly to developers coming from other frameworks. So in React 19, there had to be a few major changes. You can check out the full code and demo for further reference.

React 19's new approach: Actions

React 19 introduced Actions, asynchronous functions that handle form submissions directly. With the new approach in React, we will now treat form inputs as elements that do not need to be tracked in React state. This takes advantage of the browser's built-in form-handling qualities. Here's how the form from above would look in React 19:

function ContactForm() {
  const submitContact = async (prevState, formData) => {
    const name = formData.get('name');
    const email = formData.get('email');

    // Process form data (e.g., send to API)
    // No need to preventDefault or reset form - React handles it
    return { success: true, message: `Thanks ${name}!` };
  };

  const [state, formAction] = useActionState(submitContact, {});

  return (
    <form action={formAction}>
      <input type="text" name="name" />
      <input type="email" name="email" />
      <SubmitButton />
      {state.success && <p>{state.message}</p>}
    </form>
  );
}

function SubmitButton() {
  const { pending } = useFormStatus();
  return (
    <button disabled={pending}>
      {pending ? 'Submitting...' : 'Submit'}
    </button>
  );
}

Native HTML form behavior

React 19 embraces standard HTML form behavior by using the action attribute instead of onSubmit. This means:

  • A big No to the annoying e.preventDefault()
  • An automatic form resets after a successful submission
  • Form state will now be tracked by the browser, and not React

FormData API

Actions receive a FormData object, which is a native browser API. You access values with:

formData.get('fieldName'); // Gets single value
formData.getAll('multipleSelect'); // Gets multiple selected values

N.B., you'll need to convert this to a regular object if sending as JSON:

const payload = Object.fromEntries(formData.entries());

New hooks for form state

React 19 introduces new hooks that save you a few lines of code. These hooks include:

  • useActionState: This connects forms to action functions and tracks the response state
  • useFormStatus: Provides submission status (pending, loading, etc.)

No input state management

Perhaps the biggest and yet one of my favorite quality-of-life improvements from React is that inputs no longer need state:

// React 18
<input value={email} onChange={(e) => setEmail(e.target.value)} />

// React 19
<input name="email" />

The browser takes charge of managing the input state for you!

Server Actions integration

If you're using a framework like Next.js, React 19's form Actions seamlessly connect with server Actions:

function ContactForm() {
  async function submitToServer(prevState, formData) {
    'use server'; // This marks it as a server action
    // Process server-side (database, etc.)
    return { success: true };
  }

  const [state, formAction] = useActionState(submitToServer, {});

  return (
    <form action={formAction}>
      {/* form inputs */}
    </form>
  );
}

What about validation?

This is where React 19 still has some gaps. So far, there's no built-in validation system beyond standard HTML validation attributes (required, pattern, etc.). For complex validation, I advise you to use a few options:

  • Use the standard HTML validation attributes
  • Implement custom validation in your action function
  • Employ the use of form libraries like React Hook Form
  • Use a schema validation library like Zod with your action

React 19 form handling vs. React Hook Form

The table highlights the differences and similarities of this to useful form integrations:

Feature React Hook Form React 19 native forms
Built-in form handling No, requires RHF setup Yes, now built-in
Validation Supports external validation libraries (Zod, Yup) Basic HTML validation with manual implementation
Performance Optimized for large forms, Minimal re-renders
Form submission Controlled via hooks (handleSubmit) More declarative in React 19
Learning curve Simple with a custom API to learn Low because it follows HTML standards
Complex forms It has a built-in solution Will require a custom code
TypeScript support Excellent Basic
Error handling Built-in support Manual implementation
Field arrays Built-in support Manual implementation
Bundle size ~13kb (minified + gzipped) No extra size as it is part of React

When to use React Hook Form?

You will likely prefer using React Hook Form for the following use cases:

Large-scale forms with performance concerns

React Hook Form performs at a better level when managing large forms with a particularly large number of fields. This is how you would use it:

function LargeApplicationForm() {
  // RHF only re-renders fields that change, not the entire form
  const { register, handleSubmit, formState } = useForm({
    mode: "onChange", // Validate on change for immediate feedback
    defaultValues: {
      personalInfo: { firstName: "", lastName: "", email: "" },
      address: { street: "", city: "", zipCode: "" },
      employment: { company: "", position: "", yearsOfExperience: "" },
      education: { degree: "", institution: "", graduationYear: "" },
      // ... dozens more fields
    }
  });

  return (
    <form onSubmit={handleSubmit(onSubmit)}>
      <PersonalInfoSection register={register} errors={formState.errors} />
      <AddressSection register={register} errors={formState.errors} />
      {/* Many more sections */}
      <button type="submit">Submit Application</button>
    </form>
  );
}

The code above ensures that when a user types in one field, only that specific field re-renders, not the whole form.

Dynamic form fields

Working with dynamic fields (adding/removing items) is much simpler with RHF's useFieldArray Hook:

function OrderForm() {
  const { control, register, handleSubmit } = useForm({
    defaultValues: {
      items: [{ product: "", quantity: 1, price: 0 }]
    }
  });

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

  // Calculate total order value
  const watchItems = useWatch({ control, name: "items" });
  const total = watchItems.reduce((sum, item) => 
    sum + (item.quantity * item.price), 0);

  return (
    <form onSubmit={handleSubmit(onSubmit)}>
      {fields.map((field, index) => (
        <div key={field.id} className="item-row">
          <select {...register(`items.${index}.product`)}>
            <option value="">Select Product</option>
            <option value="product1">Product 1</option>
            <option value="product2">Product 2</option>
          </select>

          <input 
            type="number" 
            {...register(`items.${index}.quantity`, { 
              valueAsNumber: true,
              min: 1
            })} 
          />

          <input 
            type="number" 
            {...register(`items.${index}.price`, { 
              valueAsNumber: true 
            })}
          />

          <button type="button" onClick={() => remove(index)}>
            Remove
          </button>

          {/* Move up/down buttons */}
        </div>
      ))}

      <button type="button" onClick={() => append({ product: "", quantity: 1, price: 0 })}>
        Add Item
      </button>

      <div>Total: ${total.toFixed(2)}</div>
      <button type="submit">Place Order</button>
    </form>
  );
}

Complex validation rules

RHF's integration with validation libraries like Zod makes complex validation much easier:

// Define complex schema with interdependent validations
const schema = z.object({
  password: z.string()
    .min(8, "Password must be at least 8 characters")
    .regex(/[A-Z]/, "Password must contain at least one uppercase letter")
    .regex(/[a-z]/, "Password must contain at least one lowercase letter")
    .regex(/[0-9]/, "Password must contain at least one number")
    .regex(/[^A-Za-z0-9]/, "Password must contain at least one special character"),
  confirmPassword: z.string(),
  // More fields...
}).refine(data => data.password === data.confirmPassword, {
  message: "Passwords don't match",
  path: ["confirmPassword"]
});

function RegistrationForm() {
  const { register, handleSubmit, formState: { errors } } = useForm({
    resolver: zodResolver(schema),
    mode: "onBlur" // Validate fields when they lose focus
  });

  return (
    <form onSubmit={handleSubmit(onSubmit)}>
      <div>
        <label>Password</label>
        <input type="password" {...register("password")} />
        {errors.password && <p className="error">{errors.password.message}</p>}
      </div>

      <div>
        <label>Confirm Password</label>
        <input type="password" {...register("confirmPassword")} />
        {errors.confirmPassword && <p className="error">{errors.confirmPassword.message}</p>}
      </div>

      {/* More fields */}
      <button type="submit">Register</button>
    </form>
  );
}

Integration with UI component libraries

RHF integrates seamlessly with component libraries like Shadcn UI:

import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import * as z from "zod";
import { Button } from "@/components/ui/button"; // shadcn/ui Button
import {
  Form,
  FormField,
  FormItem,
  FormLabel,
  FormControl,
  FormMessage,
} from "@/components/ui/form"; // shadcn/ui Form components
import { Input } from "@/components/ui/input"; // shadcn/ui Input
import {
  Select,
  SelectTrigger,
  SelectValue,
  SelectContent,
  SelectItem,
} from "@/components/ui/select"; // shadcn/ui Select components

// Define schema (assumed missing in your example)
const formSchema = z.object({
  username: z.string().min(1, "Username is required"),
  email: z.string().email("Invalid email address"),
  role: z.enum(["admin", "user", "editor"]),
  isActive: z.boolean(),
});

function UserForm() {
  const form = useForm({
    resolver: zodResolver(formSchema),
    defaultValues: {
      username: "",
      email: "",
      role: "user",
      isActive: true,
    },
  });

  const onSubmit = (data) => {
    console.log("Form submitted:", data);
  };

  return (
    <Form {...form}>
      <form onSubmit={form.handleSubmit(onSubmit)} className="space-y-8">
        <FormField
          control={form.control}
          name="username"
          render={({ field }) => (
            <FormItem>
              <FormLabel>Username</FormLabel>
              <FormControl>
                <Input placeholder="johndoe" {...field} />
              </FormControl>
              <FormMessage />
            </FormItem>
          )}
        />

        <FormField
          control={form.control}
          name="role"
          render={({ field }) => (
            <FormItem>
              <FormLabel>Role</FormLabel>
              <Select onValueChange={field.onChange} defaultValue={field.value}>
                <FormControl>
                  <SelectTrigger>
                    <SelectValue placeholder="Select a role" />
                  </SelectTrigger>
                </FormControl>
                <SelectContent>
                  <SelectItem value="admin">Admin</SelectItem>
                  <SelectItem value="user">User</SelectItem>
                  <SelectItem value="editor">Editor</SelectItem>
                </SelectContent>
              </Select>
              <FormMessage />
            </FormItem>
          )}
        />

        {/* Example additional field */}
        <FormField
          control={form.control}
          name="email"
          render={({ field }) => (
            <FormItem>
              <FormLabel>Email</FormLabel>
              <FormControl>
                <Input placeholder="john@example.com" {...field} />
              </FormControl>
              <FormMessage />
            </FormItem>
          )}
        />

        <Button type="submit">Submit</Button>
      </form>
    </Form>
  );
}

export default UserForm;

Each of the examples above highlights and demonstrates different use cases where you might prefer using React Hook Form because it provides more substantial value beyond what React 19's native form handling currently offers.

How to use React Hook Form and React 19 form Actions the right way

When combining React Hook Form with React 19's form Actions, it's important to create a seamless integration between client-side validation and server-side processing. Here's how to implement this pattern correctly:

Integration pattern

The key to proper integration lies in using useFormState for server state management while leveraging React Hook Form's validation advantage. Here's a straightforward implementation:

  • First, define your form schema using Zod for type-safe validation
  • Create a server action that processes form submissions
  • Connect React Hook Form with the server action using useFormState
  • Use form state to display errors and retain field values

Here’s how this works:

"use client";
import { zodResolver } from "@hookform/resolvers/zod";
import { useFormState } from "react-dom";
import { useRef } from "react";
import { useForm } from "react-hook-form";
import { Button } from "@/components/ui/button";
import { Form, FormControl, FormField, FormItem, FormMessage } from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import { z } from "zod";

// 1\. Define form schema with Zod
const schema = z.object({
  email: z.string().email(),
  name: z.string().min(2),
});

// 2\. Define server action type
type FormState = {
  message: string;
  fields?: Record<string, string>;
  issues?: string[];
};

// Server action (should be in a separate file with "use server")
// export async function formAction(prevState: FormState, formData: FormData): Promise {
//   const fields = Object.fromEntries(formData);
//   if (!fields.email.includes("@")) {
//     return { message: "Invalid email", issues: ["Email must contain @"] };
//   }
//   return { message: "Success", fields };
// }

export function MyForm() {
  // 3\. Connect React Hook Form with server action
  const [state, formAction] = useFormState(formAction, { message: "" });
  const formRef = useRef<HTMLFormElement>(null);

  const form = useForm<z.infer<typeof schema>>({
    resolver: zodResolver(schema),
    defaultValues: {
      email: state?.fields?.email || "",
      name: state?.fields?.name || "",
    },
  });

  return (
    <Form {...form}>
      {/* Display server-side errors */}
      {state?.issues && (
        <div className="text-red-500">
          {state.issues.map((issue) => (
            <div key={issue}>{issue}</div>
          ))}
        </div>
      )}

      <form
        ref={formRef}
        action={formAction}
        onSubmit={form.handleSubmit(() => {
          formAction(new FormData(formRef.current!));
        })}
      >
        <FormField
          control={form.control}
          name="name"
          render={({ field }) => (
            <FormItem>
              <FormControl>
                <Input placeholder="Name" {...field} />
              </FormControl>
              <FormMessage /> {/* Client-side errors */}
            </FormItem>
          )}
        />

        <FormField
          control={form.control}
          name="email"
          render={({ field }) => (
            <FormItem>
              <FormControl>
                <Input placeholder="Email" {...field} />
              </FormControl>
              <FormMessage /> {/* Client-side errors */}
            </FormItem>
          )}
        />

        <Button type="submit">Submit</Button>
      </form>
    </Form>
  );
}

In the code above, we put together two submission mechanisms to create a smooth validation flow from client to server. These mechanisms are React Hook Form's client-side validation and submission, and React 19's native form Actions (server-side processing). Remember when we highlighted that in React 19 form Actions, you won’t need preventDefault()?

Well, that's when you will not be using RHF. If you are using RHF, we will need to use preventDefault() to temporarily stop the native form submission that would normally happen immediately when the form is submitted:

onSubmit={(evt) => {
  evt.preventDefault();
  form.handleSubmit(() => {
    formAction(new FormData(formRef.current!));
  })(evt);
}}

The highlighted code above gives React Hook Form a chance to run its client-side validation first, which is handled by form.handleSubmit(). If the client-side validation passes — that is, all fields are valid according to our Zod schema — then the callback function inside handleSubmit() runs, which manually triggers our server action with formAction(new FormData(...)). If validation fails, React Hook Form will display error messages, and the server action won't be called at all.

Does React 19 replace React Hook Form?

The straight answer is no, React 19 doesn’t replace React Hook Form. This is because React 19 introduces new form handling features like useActionState, useFormStatus, and useOptimistic, but React Hook Form remains a standalone library offering additional flexibility, validation, and performance optimizations not fully covered by React 19’s native tools.

The benefits of using React Hook Form over React 19’s form handling

  • Performance: React Hook Form uses uncontrolled components, reducing re-renders compared to React 19’s controlled-focused approach
  • Validation: Offers built-in, customizable validation with support for libraries like Yup or Zod, while React 19 requires manual setup
  • Flexibility: It works seamlessly with complex forms, multi-step workflows, and UI libraries

Use React Hook Form when:

  • You need high-performance forms with minimal re-renders
  • Your project requires complex validation
  • You’re integrating with UI libraries or need a lightweight solution (React Hook Form has no dependencies)
  • Managing dynamic form fields (arrays, nested fields)
  • Working with existing codebases that already use RHF
  • TypeScript integration is a priority
  • Advanced form patterns like conditional fields are needed
  • When you need to establish patterns and don't want to reinvent the solution

The biggest performance advantages of React Hook Form

  • Uncontrolled components: Leverages DOM refs instead of state, minimizing re-renders on every input change
  • Fewer re-renders: Isolates input updates, avoiding full form re-renders, unlike controlled setups
  • Small bundle size: At ~9KB (minified), RHF is lightweight with zero dependencies, enhancing load times
  • Fast mounting: RHF mounts forms quicker by avoiding unnecessary state updates, outperforming traditional controlled form libraries

Should I drop form libraries?

It depends on your needs. For simple forms, React 19's native approach is likely sufficient. For complex forms with lots of validation, form libraries still provide a lot of value. The good news is that React 19 gives you options. You can use its simplified approach for simpler forms while reserving more complex libraries for forms that need advanced validation and state management, as we have seen already.

Conclusion

React Hook Form is an excellent addition to the React open source ecosystem, significantly simplifying the creation and maintenance of forms. Its greatest strengths include its focus on the developer experience and its flexibility. It integrates seamlessly with state management libraries and works excellently in React Native. Until next time, stay safe and keep building more forms. Cheers ✌

Get set up with LogRocket's modern React error tracking in minutes:

  1. Visit https://logrocket.com/signup/ to get an app ID.
  2. Install LogRocket via NPM or script tag. LogRocket.init() must be called client-side, not server-side.

NPM:

$ npm i --save logrocket 

// Code:

import LogRocket from 'logrocket'; 
LogRocket.init('app/id');

Script Tag:

Add to your HTML:

<script src="https://cdn.lr-ingest.com/LogRocket.min.js"></script>
<script>window.LogRocket && window.LogRocket.init('app/id');</script>

3.(Optional) Install plugins for deeper integrations with your stack:

  • Redux middleware
  • ngrx middleware
  • Vuex plugin

Get started now.