Why would I use React Hook Form?

Forms are some of the more complicated things we do in the web, and we don’t always do them well. React Hook Form can be a way to tame this complexity, so let’s go through it.

Matt Burgess
19 min readDec 19, 2024

We’ve spoken before about what React’s job is — converting an internal state to a UI. That’s really all it does. One of the key places React does that is in forms. Forms are bread-and-butter UI for any web application and really the only way to enter non-trivial information into a web application.

But there are right and wrong ways to do forms. Or more particularly there are more and less maintainable ways to do forms.

Unlike other articles where I’ve talked in some detail about what the library does or what benefits it has, this one will be a show-don’t-tell, so we’re going to start off with a “bad” form and refactor it.

Bad is all relative. It gets the job done, right? But we care about maintainability, we want to reduce the size of our code. And forms are one area that can really balloon out. Validation requirements, styling, error handling, submission, inflight checks, and so on. This can end up with a lot of state that we’re maintaining.

Our Code

This is our starting component. It’s written in JavaScript instead of TypeScript so we can introduce stuff a bit at a time. We’ll get to TypeScript in a bit. If you’re not using TypeScript because you haven’t had the chance to learn it yet, it’s time to step up. I’m also going to style it with Tailwind, because that’s pretty much how I do everything now.

Let me apologise in advance for the big block of code, but we have to establish a baseline.

import React, { useState, useEffect } from 'react';

function BadSignupForm() {
const [firstName, setFirstName] = useState('');
const [lastName, setLastName] = useState('');
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [confirmPassword, setConfirmPassword] = useState('');
const [termsAgreed, setTermsAgreed] = useState(false);
const [isFormValid, setIsFormValid] = useState(false);
const [fieldErrors, setFieldErrors] = useState({});
const [submitted, setSubmitted] = useState(false);

// Validate the form and fields whenever any field changes
useEffect(() => {
const errors = {};

if (firstName.trim() === '') {
errors.firstName = 'First name cannot be empty.';
}

if (lastName.trim() === '') {
errors.lastName = 'Last name cannot be empty.';
}

if (!email.includes('@')) {
errors.email = 'Invalid email address.';
}

if (password.length <= 5) {
errors.password = 'Password must be at least 6 characters long.';
}

if (confirmPassword !== password) {
errors.confirmPassword = 'Passwords do not match.';
}

setFieldErrors(errors);

const isEmailValid = email.includes('@');
const isPasswordValid = password.length > 5;
const isConfirmPasswordValid = confirmPassword === password;
const areNamesValid = firstName.trim() !== '' && lastName.trim() !== '';
const areTermsValid = termsAgreed;

setIsFormValid(
isEmailValid &&
isPasswordValid &&
isConfirmPasswordValid &&
areNamesValid &&
areTermsValid
);
}, [firstName, lastName, email, password, confirmPassword, termsAgreed]);

const handleSubmit = (e) => {
e.preventDefault();
const payload = {
firstName,
lastName,
email,
password,
termsAgreed,
};

fetch('/users', {
method: 'POST',
headers: { 'Content-Type': 'application/json',},
body: JSON.stringify(payload),
}).then(() => {
setSubmitted(true);
});

};

const handleTermsClick = () => {
setTermsAgreed(!termsAgreed);
};

return (
<div className="p-4 max-w-md mx-auto bg-gray-100 rounded-lg shadow-sm">
{!submitted ? (
<>
<h1 className="text-xl font-bold mb-4">Create Account</h1>
<div className="mb-4">
<div className="mb-1 font-semibold">First Name</div>
<input
className="w-full border rounded-sm p-2"
type="text"
value={firstName}
onChange={(e) => setFirstName(e.target.value)}
/>
{fieldErrors.firstName && (
<div className="text-red-500 text-sm mt-1">{fieldErrors.firstName}</div>
)}
</div>
<div className="mb-4">
<div className="mb-1 font-semibold">Last Name</div>
<input
className="w-full border rounded-sm p-2"
type="text"
value={lastName}
onChange={(e) => setLastName(e.target.value)}
/>
{fieldErrors.lastName && (
<div className="text-red-500 text-sm mt-1">{fieldErrors.lastName}</div>
)}
</div>
<div className="mb-4">
<div className="mb-1 font-semibold">Email</div>
<input
className="w-full border rounded-sm p-2"
type="text"
value={email}
onChange={(e) => setEmail(e.target.value)}
/>
{fieldErrors.email && (
<div className="text-red-500 text-sm mt-1">{fieldErrors.email}</div>
)}
</div>
<div className="mb-4">
<div className="mb-1 font-semibold">Password</div>
<input
className="w-full border rounded-sm p-2"
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
/>
{fieldErrors.password && (
<div className="text-red-500 text-sm mt-1">{fieldErrors.password}</div>
)}
</div>
<div className="mb-4">
<div className="mb-1 font-semibold">Confirm Password</div>
<input
className="w-full border rounded-sm p-2"
type="password"
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
/>
{fieldErrors.confirmPassword && (
<div className="text-red-500 text-sm mt-1">{fieldErrors.confirmPassword}</div>
)}
</div>
<div className="mb-4 flex items-center">
<input
className="mr-2"
type="checkbox"
checked={termsAgreed}
onChange={() => setTermsAgreed(!termsAgreed)}
/>
<span
onClick={handleTermsClick}
className="cursor-pointer text-blue-600 underline"
>
I agree to the terms and conditions
</span>
</div>
<div>
<button
onClick={handleSubmit}
disabled={!isFormValid}
className={`w-full py-2 rounded-sm font-semibold ${
isFormValid ? 'bg-green-400 text-white' : 'bg-gray-300 text-gray-600'
}`}
>
Sign Up
</button>
</div>
</>
) : (
<div className="text-center">
<h1 className="text-2xl font-bold mb-4">Thank You!</h1>
<p className="text-gray-700">
Your registration was successful. You can now log in with your new
account.
</p>
</div>
)}
</div>
);
}

export default BadSignupForm;

Ok, so that’s a component. It’s really long, but it’s a form, and it works. But it can be better. For a start, we can do some better React, but critically, we can also just do better web.

What do I mean by that? Well, just the most basic things. Our form… is not a form. Our button is not a button. Using the most fundamental semantic tags alone makes a big difference to useability. Things like tabbing through the form, or submitting with the enter key can come along with these trivial changes for free.

A lot of the time we talk about “accessibility” like it’s just allowing the blind to use your application. But accessibility is really about broad usability. Much of it is baked into the browser by default and it’s often something that we actively remove. Maybe we shouldn’t.

While we’re at it, now that we’re treating the form as a form we no longer want to have “clicking the button” be the event we want to trigger. No, what we actually want is for the event to be “submitting the form”. Because that’s what we’re actually doing.

We also have labels here, but they’re not really a label, they’re just a div. We get significant benefits from using labels and setting them up correctly. A valid label associated with a field can be a major boon to usability and makes testing much easier.

In particular do you see the terms and conditions checkbox? We want the ability to click the checkbox, but also to click the text. This is a bit of a contrived example, but it’s routine in longer lists of options. This is a good example of where proper structure makes code irrelevant. By wrapping the whole block in a label instead of just being a div you don’t need handle the click, it’s just… how forms work.

Having a good understand of how forms work, how fields work, how submission works, what events occur and why… these things are critical.

Let’s clean up the form. Let’s make the events right, make it a submit button, implement proper labels and structure and handle the form submission. I’ll ditch most of the state stuff and just implement the form part.

<form onSubmit={handleSubmit}>
<h1 className="text-xl font-bold mb-4">Create Account</h1>
<div className="mb-4">
<label htmlFor="firstName" className="mb-1 font-semibold">First Name</label>
<input
className="w-full border rounded-sm p-2"
type="text"
name="firstName"
value={firstName}
onChange={(e) => setFirstName(e.target.value)}
/>
{fieldErrors.firstName && (
<div className="text-red-500 text-sm mt-1">{fieldErrors.firstName}</div>
)}
</div>
<div className="mb-4">
<div className="mb-1 font-semibold">Last Name</div>
<input
className="w-full border rounded-sm p-2"
type="text"
value={lastName}
onChange={(e) => setLastName(e.target.value)}
/>
{fieldErrors.lastName && (
<div className="text-red-500 text-sm mt-1">{fieldErrors.lastName}</div>
)}
</div>
<div className="mb-4">
<div className="mb-1 font-semibold">Email</div>
<input
className="w-full border rounded-sm p-2"
type="text"
value={email}
onChange={(e) => setEmail(e.target.value)}
/>
{fieldErrors.email && (
<div className="text-red-500 text-sm mt-1">{fieldErrors.email}</div>
)}
</div>
<div className="mb-4">
<div className="mb-1 font-semibold">Password</div>
<input
className="w-full border rounded-sm p-2"
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
/>
{fieldErrors.password && (
<div className="text-red-500 text-sm mt-1">{fieldErrors.password}</div>
)}
</div>
<div className="mb-4">
<div className="mb-1 font-semibold">Confirm Password</div>
<input
className="w-full border rounded-sm p-2"
type="password"
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
/>
{fieldErrors.confirmPassword && (
<div className="text-red-500 text-sm mt-1">{fieldErrors.confirmPassword}</div>
)}
</div>
<div className="mb-4 flex items-center">
<label className="cursor-pointer text-blue-600 underline" htmlFor="termsAgreed"
>
<input
className="mr-2"
name="termsAgreed"
type="checkbox"
checked={termsAgreed}
onChange={() => setTermsAgreed(!termsAgreed)}
/>

I agree to the terms and conditions
</label>
</div>
<div>
<button
type="submit"
disabled={!isFormValid}
className={`w-full py-2 rounded-sm font-semibold ${
isFormValid ? 'bg-green-400 text-white' : 'bg-gray-300 text-gray-600'
}`}
>
Sign Up
</button>
</div>
</form>

Ok, so that’s an improvement, let’s add React Hook Form, usage of which takes this basic format. We’ll worry about submitting it in a minute, but for now this is what we need. Create the useForm hook, and then destructure out the register function. Then we need to use register to make sure that the fields are assigned to the form.


const { register } = useForm()
...
<input {...register(‘firstName’)} />

Couple of quick things on this register function. This is really the key function of RFH, possibly except for the initial useForm hook. That’s pretty important too.

The register function returns a bunch of other function, including a bunch of props like the name, id, value setting function and on change handlers. That’s all built in. This why we spread the function here. Make sure you spread rather than just putting in {register('name’)}. I’m not saying you’re stupid, I’m saying I’m stupid. I’ve lost count.

This simple usage will take us a long way, and the optional second argument of “registerOptions” takes us a long way further. Register options lets us set basic HTML properties like max length, or required. Critically it also allows us to set validation, which we’ll do later.

At this point the useForm hook is essentially creating a bunch of internally managed state props. This means we can get rid of the manual state values for our form fields.

import React, { useState, useEffect } from 'react';
import { useForm } from "react-hook-form";

function BetterSignupForm() {
const { register, watch } = useForm();
const [isFormValid, setIsFormValid] = useState(false);
const [fieldErrors, setFieldErrors] = useState({});
const [submitted, setSubmitted] = useState(false);

const {firstName, lastName, email, password, confirmPassword} = watch()

// Validate the form and fields whenever any field changes
useEffect(() => {
// validation block
}, [firstName, lastName, email, password, confirmPassword, termsAgreed]);

const handleSubmit = () => {
// submit form from before
};

return (
<div className="p-4 max-w-md mx-auto bg-gray-100 rounded-lg shadow-sm">
{!submitted ? (
<form onSubmit={handleSubmit}>
<h1 className="text-xl font-bold mb-4">Create Account</h1>
<div className="mb-4">
<div className="mb-1 font-semibold">First Name</div>
<input
className="w-full border rounded-sm p-2"
{...register('firstName')}
/>
{fieldErrors.firstName && (
<div className="text-red-500 text-sm mt-1">{fieldErrors.firstName}</div>
)}
</div>
// ... rest of the form
</form>
) : (
<div className="text-center">
<h1 className="text-2xl font-bold mb-4">Thank You!</h1>
<p className="text-gray-700">
Your registration was successful. You can now log in with your new
account.
</p>
</div>
)}
</div>
);
}

export default BadSignupForm;

Submitting our form

This is obviously not of any value if we can’t submit it, and we’re already pretty close. We can use our existing submit handler, but we need to destructure out the handleSubmit function from React Hook Form to join register and watch.

const { register, watch, handleSubmit } = useForm()

With that we can have it handle the submission.

This is probably the least intuitive part of RHF, so let’s clarify something. The reason you have a function you call is that the function actually has two arguments, the success handler and the error handler, though the latter is rarely used in practice. This is why the handleSubmit function seems redundant but actually isn’t. You do want to use it.

So now we’ve lost all of our state properties, so where are all the form values? Well, they’re actually passed into the submit function as the payload automatically. You don’t need to get them out, all registered values are sent in here, so we can just send this on. What we are going to have to do is get rid of our confirmPassword field though. We don’t want it in our payload to the server, but that’s a rest syntax away.

const handleSubmit = (payload) => {
const { confirmPassword, ...remaining}
fetch('/users', {
method: 'POST',
headers: { 'Content-Type': 'application/json',},
body: JSON.stringify(remaining),
});
};

We’ve lost two things here. One is that we don’t need the preventDefault anymore, RHF handles that. The other is that we don’t actually need to set the submitted value. We’ll come back to that later and restore that functionality.

Field Validation

We’ve got our field state being managed, but we’ve still got a lot of validation rules. Let’s start reimplementing it by creating default values for the useForm call. This is considered best-practice in RHF usage, and helps the guts of the thing figure out whether values have changed, allows you to reset, etc.

const { register, watch, handleSubmit } = useForm({ defaultValues: {
firstName: "",
lastName: "",
email: "",
password: "",
confirmPassword: "",
termsAgreed: false
}})

With that we can also create validation on the register options. The simplest form of this validation is a single “required” property, with the error string. Then there is the validate field as a function, which takes the value as its argument. This function returns true, or a string that constitutes an error message. This is a little counter-intuitive, because it’s “truthy” in both cases.

We can do a super simple one for our email field. We don’t really want complex regex here, just the most basic “seems like it might be an email”.

// just required
<input
className="w-full border rounded-sm p-2"
{...register('firstName', {
required: 'First name cannot be empty.',
})}
/>
{errors.firstName && (
<div className="text-red-500 text-sm mt-1">{errors.firstName.message}</div>
)}
// slightly more complex check for validity as well.
<input
className="w-full border rounded-sm p-2"
type="text"
{...register('email', {
required: 'Email is required.',
validate: (value) =>
value.includes('@') || 'Invalid email address.',
})}
/>
{errors.email && (
<div className="text-red-500 text-sm mt-1">{errors.email.message}</div>
)}

There is a more advanced version for more complex validation. Particularly there could be multiple different validation rules, and we want to both run them together and also want to know which one didn’t pass. This format lets us make a validation object instead of function. Each key is an individual function with its own return.

<input
className="w-full border rounded-sm p-2"
type="password"
{...register('password', {
required: 'Password is required.',
validate: {
longEnough: (value) =>
value.length >= 6 || 'Password must be at least 6 characters long.',
containsSymbol: (value) =>
/[!@#$%^&*(),.?":{}|<>]/.test(value) ||
'Password must contain at least one special character.',
},
})}
/>
{errors.password && (
<div className="text-red-500 text-sm mt-1">
{errors.password.message || Object.values(errors.password.types).join(', ')}
</div>
)}

You’ll notice from this that the errors.password isn’t the same, it’s now got some values called `types` that consists of all of the different values.

There is a last-but-not-least option for validation, which we’ll use for the confirmPassword field. That field validation actually needs to know not just the current field’s value, but the value of the other fields as well — specifically the password. Thankfully these are available on the second argument of the validate functions.

{...register('confirmPassword', { 
required: 'Please confirm your password.',
validate: (value, allValues) => {
return value === allValues.password || 'Passwords do not match.'
},
})}

Handling Errors

In fact we’re doing this validation wrong because we’ve shifted from our fieldErrors object to this random errors object. This actually comes from `formState` which comes from the useForm call. There are actually a bunch of useful values that we can get out of the formState. We can destructure out `errors` from `formState`.

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

So now we can use that error, and get rid of all of our state that was handling password rules, as well as the useEffect. One of the most potent and valuable parts of React Hook Form is that the combination of validation rules is automatically implemented as isValid in the formState. There is also a useful field of isSubmitted that we can pull out to restore that value.

We’ve made a lot of changes so let’s consolidate them and show the current iteration of our component:

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

function GoodSignupForm() {
const { register, watch, handleSubmit, formState: { isSubmitted, isValid } } = useForm({ defaultValues: {
firstName: "",
lastName: "",
email: "",
password: "",
confirmPassword: "",
termsAgreed: false
}})


const onSubmit = (e) => {
const payload = { firstName, lastName, email, password, termsAgreed };

fetch('/users', {
method: 'POST',
headers: { 'Content-Type': 'application/json',},
body: JSON.stringify(payload),
})
};

return (
<div className="p-4 max-w-md mx-auto bg-gray-100 rounded-lg shadow-sm">
{!isSubmitted ? (
<form onSubmit={handleSubmit(onSubmit)}>
<h1 className="text-xl font-bold mb-4">Create Account</h1>
<div className="mb-4">
<label className="mb-1 font-semibold" htmlFor="firstName">First Name</label>
<input className="w-full border rounded-sm p-2" type="text"
{...register('firstName', {required: "First name is required"})}
/>
{errors.firstName && (
<div className="text-red-500 text-sm mt-1">{errors.firstName}</div>
)}
</div>
<div className="mb-4">
<label className="mb-1 font-semibold" htmlFor="lastName">Last Name</label>
<input
className="w-full border rounded-sm p-2"
{...register('lastName', {required: "Last name is required" })}
/>
{fieldErrors.lastName && (
<div className="text-red-500 text-sm mt-1">{fieldErrors.lastName}</div>
)}
</div>
<div className="mb-4">
<label className="mb-1 font-semibold" htmlFor="email">Email</label>
<input className="w-full border rounded-sm p-2"
type="email"
{...register('email', {
required: 'Email is required.',
validate: (value) => {
return value.includes('@') || 'Invalid email address.'
},
}
)} />
{fieldErrors.email && (
<div className="text-red-500 text-sm mt-1">{fieldErrors.email}</div>
)}
</div>
<div className="mb-4">
<label className="mb-1 font-semibold" htmlFor="password">Password</label>
<input className="w-full border rounded-sm p-2"
type="password"
{...register('password', {
required: 'Password is required.',
validate: {
longEnough: (value) => {
return value.length >= 6 || 'Password must be at least 6 characters long.'
},
containsSymbol: (value) => {
return /[!@#$%^&*(),.?":{}|<>]/.test(value) ||
'Password must contain at least one special character.'
},
},
}
)} />
{fieldErrors.password && (
<div className="text-red-500 text-sm mt-1">{fieldErrors.password}</div>
)}
</div>
<div className="mb-4">
<label className="mb-1 font-semibold" htmlFor="confirmPassword">Confirm Password</label>
<input
className="w-full border rounded-sm p-2"
type="password"
{...register('confirmPassword', {
required: 'Please confirm your password.',
validate: (value, allValues) => value === allValues.password || 'Passwords do not match.'
}
)}/>
{fieldErrors.confirmPassword && (
<div className="text-red-500 text-sm mt-1">{fieldErrors.confirmPassword}</div>
)}
</div>
<div className="mb-4 flex items-center">
<label
htmlFor="termsAgreed"
className="cursor-pointer text-blue-600 underline"
>
<input
className="mr-2"
type="checkbox"
{...register('termsAgreed', {required: "You must accept the terms and conditions." })}
/>
I agree to the terms and conditions
</label>
</div>
<div>
<button
type="submit"
disabled={!isValid}
className={`w-full py-2 rounded-sm font-semibold ${
isFormValid ? 'bg-green-400 text-white' : 'bg-gray-300 text-gray-600'
}`}
>
Sign Up
</button>
</div>
</form>
) : (
<div className="text-center">
<h1 className="text-2xl font-bold mb-4">Thank You!</h1>
<p className="text-gray-700">
Your registration was successful. You can now log in with your new
account.
</p>
</div>
)}
</div>
);
}

export default GoodSignupForm;

You can see how much code we’ve removed here. We’re not dealing with a bunch of independent state functions and that greatly improves the maintainability. While we still have quite a lot of visual clutter, much of this is our validation rules.

Async validation

Validation rules don’t have to be just simple checks, either. We don’t really need that right now, but it’s nice to know.

Getting Values: Watch vs getValues

We routinely want to see or access the current value of a field. Here we’re using it for the useEffect, and we can do that with watch.

There are two almost identical bits of functionality to get the values inside our form. One is the watch function, the other is the getValues function. In theory they’re the same values, but in practice the getValues function is not fully reactive, and the watch values are. Watch values will always be correct and up to date. If you’re displaying a summary or something like that, getValues is great. If you’re calculating something based on the form values you’ll be wanting to use watch.

There is also a setValue function which we won’t need to use but it works perfectly well and does what it says on the tin. One thing to note is that setValue doesn’t run any validation by default, it just updates the internal form state value. You can add an option to it to make it run validation. Or you can not. Up to you.

Making things Harder with TypeScript

I wrote the previous code in JavaScript, but the fact is I don’t use plain JavaScript for anything these days. Most people are using TypeScript and I’m no exception. I didn’t want to overload the functionality demonstrated above, but thankfully TypeScript is well supported in RHF. The main thing you want to do is make your `useForm` function a generic. this then gets a type of your fields. I usually make an interface of each form, import it as FormFields and go about my business.

This now gives you a lot of type safety, and lets you know in advance what is in your payloads, watch values, etc.

type FormFields = {
firstName: string,
lastName: string,
email: string,
password: string,
confirmPassword: string,
termsAgreed: boolean
}

const { register, handleSubmit, formState: { isSubmitted, isValid } } = useForm<FormFields>({ defaultValues: ... });

What I typically do in practice is make a /types/forms.ts file, and have a `RegisterFormFields` and so on, then import that.

Taken out of Context

Most documentation stops around here, with the assumption that you have four fields and they’re all stuffed in a single form tag. This is rarely the reality. Modern applications often use complex design systems and the like. None of this is a problem.

Underneath the hood RFH is actually creating and using a context. We can use and access that context directly to make more flexible and composable forms and form field components. We can do this by pulling in the FormProvider component, and spreading all of the methods into that.

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

function GoodForm() {
const methods = useForm<FormFields>({ defaultValues: ... });

const { register, handleSubmit, formState: { isSubmitted, isValid } } = methods;

<FormProvider {...methods}>
<form onSubmit={handleSubmit(onSubmit)}>

We can then extract that context in a component with useFormContext, but do be careful. If a component is given that useFormContext call and isn’t inside a FormProvider you WILL get an error. So for example in tests, or in StoryBook. You’ll need to wrap those in some kind of dummy provider. (It’s easier than it sounds.)

It’s actually been bugging me how much repeated code we have in this form so let’s fix it. We’ll make a field group component that we can reuse.

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

type Props = {
name: string;
label: string;
type?: "text" | "email";
registerOptions?: RegisterOptions
}

function FieldBlock({ name, label, type="text", registerOptions = {}}: Props) {
const { register, formState: { errors } } = useFormContext();
return (<div className="mb-4">
<label className="mb-1 font-semibold" htmlFor={name}>{label}</label>
<input className="w-full border rounded-sm p-2" type={type}
{...register(name, registerOptions)}
/>
{errors[name] && (
<div className="text-red-500 text-sm mt-1">{errors[name]}</div>
)}
</div>)
}

I should also note that when you use `useFormContext` you can and should make it a generic, pulling in the type that’s the form fields. I just didn’t want to show pulling out the type, importing it, etc. Though of course this particular component could now be used in

This will simplify our form down a lot, so let’s show that.

<form onSubmit={handleSubmit(onSubmit)}>
<h1 className="text-xl font-bold mb-4">Create Account</h1>
<FieldBlock name="firstName" label="First Name"
registerOptions={{ required: "First name is required" }} />

<FieldBlock name="lastName" label="Last Name"
registerOptions={{ required: "Last name is required" }} />

<FieldBlock name="email" label="Email Address"
registerOptions={{
required: 'Email is required.',
validate: {
longEnough: (value) => {
return value.length >= 6 || 'Password must be at least 6 characters long.'
},
containsSymbol: (value) => {
return /[!@#$%^&*(),.?":{}|<>]/.test(value) ||
'Password must contain at least one special character.'
},
},
}}
/>

<FieldBlock name="password" label="Password" registerOptions={{
required: 'Password is required.',
validate: {
longEnough: (value) =>
value.length >= 6 || 'Password must be at least 6 characters long.',
containsSymbol: (value) =>
/[!@#$%^&*(),.?":{}|<>]/.test(value) || 'Password must contain at least one special character.',
},
}} />

<FieldBlock name="confirmPassword" label="Confirm password"
registerOptions={{
required: 'Please confirm your password.',
validate: (value, allValues) => {
return value === allValues.password || 'Passwords do not match.'
}}} />

<div className="mb-4 flex items-center">
<label
htmlFor="termsAgreed"
className="cursor-pointer text-blue-600 underline"
>
<input
className="mr-2"
type="checkbox"
{...register('termsAgreed')}
/>
I agree to the terms and conditions
</label>
</div>
<div>
<button
type="submit"
disabled={!isValid}
className={`w-full py-2 rounded-sm font-semibold ${
isValid ? 'bg-green-400 text-white' : 'bg-gray-300 text-gray-600'
}`}
>
Sign Up
</button>
</div>
</form>

There’s only so much we can cut down and retain clarity, though there are a few tweaks we can make, particularly to styling. Knowing how to style a form well, is a trick. There’s a lot to good structure, including with Tailwind. Many people don’t know the options. Let’s start with the button. Technically that probably doesn’t need to be in a div because it’s full width anyway, but we’ll ignore that. What we can implement is disabled: state selector in Tailwind.

<button type="submit" disabled={!isValid}
className="w-full py-2 rounded-sm font-semibold bg-green-400 text-white disabled:bg-gray-300 disabled:text-gray-600"
>
Sign Up
</button>

Something else we can do is slightly clean up the form layout. This is a personal preference, but forms like this are more maintainable and consistent if alignment and spacing is handled in the form tag itself.

<form className="flex flex-col gap-4" onSubmit={handleSubmit(onSubmit)}>

This lets us remove the spacing on margins on the actual form field blocks. There isn’t any specific need to do this, it’s just my personal preference.

We’ve reached the end now, and there’s minimal benefit to more upgrades. We can futz around the edges, but this is what we end up with. Let’s put it all in full together. I don’t want to show the entire original component as some sort of side-by-side, but let’s do the full component anyway. Feel free to scroll to the top to make a comparison to where we end up.

I’ve extracted a few blocks of default values, types, and validation into an external file (not shown) so that they can be re-used in different forms, like update password, etc.

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

import { signupDefaultValues, validateEmail, validatePassword } from "@/types/forms.ts"

function BestSignupForm() {
const { register, handleSubmit, formState: { isSubmitted, isValid } } = useForm({ defaultValues: signupDefaultValues})


const onSubmit = (e) => {
const payload = { firstName, lastName, email, password, termsAgreed };

fetch('/users', {
method: 'POST',
headers: { 'Content-Type': 'application/json',},
body: JSON.stringify(payload),
})
};

return (
<div className="p-4 max-w-md mx-auto bg-gray-100 rounded-lg shadow-sm">
{!isSubmitted ? (
<form className="flex flex-col gap-4" onSubmit={handleSubmit(onSubmit)}>

<h1 className="text-xl font-bold">Create Account</h1>

<FieldBlock name="firstName" label="First Name"
registerOptions={{ required: "First name is required" }}
/>

<FieldBlock name="lastName" label="Last Name"
registerOptions={{ required: "Last name is required" }}
/>

<FieldBlock name="email" label="Email Address"
registerOptions={{
required: 'Email is required.',
validate: validateEmail
}}
/>

<FieldBlock name="password" label="Password" registerOptions={{
required: 'Password is required.',
validate: validatePassword
}}
/>

<FieldBlock name="confirmPassword" label="Confirm password"
registerOptions={{
required: 'Please confirm your password.',
validate: (value, allValues) => {
return value === allValues.password
|| 'Passwords do not match.'
}
}}
/>

<div className="flex items-center">
<label
htmlFor="termsAgreed"
className="cursor-pointer text-blue-600 underline"
>
<input className="mr-2" type="checkbox"
{...register('termsAgreed')}
/>
I agree to the terms and conditions
</label>
</div>
<div>
<button type="submit" disabled={!isValid} className="w-full py-2 rounded-sm font-semibold bg-green-400 text-white disabled:bg-gray-300 disabled:text-gray-600">
Sign Up
</button>
</div>
</form>
) : (
<div className="text-center">
<h1 className="text-2xl font-bold mb-4">Thank You!</h1>
<p className="text-gray-700">Your registration was successful. You can now log in with your new account.</p>
</div>
)}
</div>
);
}

export default BestSignupForm;

What we’ve done here

The result here is that we’ve got a cleaner form, with all of the book keeping done behind the scenes. This simplifies a lot of our code, and makes it more consistent and maintainable. Which should always be the goal.

Of course, React Hook Form is absolutely not the only solution to this. TanStack Form and Formik are also options and perfectly valid ones. And to be super clear, if you have a search form or something simple you absolutely don’t need something like this. But if you’re manually managing multiple dependent felds with validation then you should be looking at better solutions.

--

--

Matt Burgess
Matt Burgess

Written by Matt Burgess

Senior Web Developer based in Bangkok, Thailand. Javascript, Web and Blockchain Developer.

Responses (4)