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.
The Replay is a weekly newsletter for dev and engineering leaders.
Delivered once a week, it's your curated guide to the most important conversations around frontend dev, emerging AI tools, and the state of modern software.
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.
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.
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 emptyminlength and maxlength set the minimum and maximum length for a string input valuemin and max set the minimum and maximum values for a numerical valuetype indicates the type of the input field; it can be email, number, text, or any other standard HTML input typespattern defines a pattern for the input value using a regular expressionIf 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.
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.
useFormContext in React Hook FormuseFormContext 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>
);
};
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.
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;
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.
In React 18, we had to manually:
useStateFor 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 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>
);
}
React 19 embraces standard HTML form behavior by using the action attribute instead of onSubmit. This means:
e.preventDefault()FormData APIActions 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());
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 stateuseFormStatus: Provides submission status (pending, loading, etc.)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!
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>
);
}
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:
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 |
You will likely prefer using React Hook Form for the following use cases:
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.
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>
);
}
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>
);
}
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="[email protected]" {...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.
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:
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:
useFormStateHere’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<FormState> {
// 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.
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.
Use React Hook Form when:
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.
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 ✌
Install LogRocket via npm or script tag. LogRocket.init() must be called client-side, not
server-side
$ npm i --save logrocket
// Code:
import LogRocket from 'logrocket';
LogRocket.init('app/id');
// Add to your HTML:
<script src="https://cdn.lr-ingest.com/LogRocket.min.js"></script>
<script>window.LogRocket && window.LogRocket.init('app/id');</script>

Examine AgentKit, Open AI’s new tool for building agents. Conduct a side-by-side comparison with n8n by building AI agents with each tool.

AI agents powered by MCP are redefining interfaces, shifting from clicks to intelligent, context-aware conversations.

Learn how platform engineering helps frontend teams streamline workflows with Backstage, automating builds, documentation, and project management.

Build an AI assistant with Vercel AI Elements, which provides pre-built React components specifically designed for AI applications.
Hey there, want to help make our blog better?
Join LogRocket’s Content Advisory Board. You’ll help inform the type of content we create and get access to exclusive meetups, social accreditation, and swag.
Sign up now
3 Replies to "React Hook Form vs. React 19: Should you still use RHF in 2025?"
this post is helpful thank you for your work. But it needs updating because of the syntax updates in v7 of RHF especially with the controller. have a nice day
Hi Layla, you can check out this guide for v7 https://blog.logrocket.com/whats-new-in-react-hook-form-v7/
Hey, brilliant, but it needs expansion, like FormProvider etc. or I’d suggest you to remove the “Complete” from the title 🙂