Understanding Register
What Does Register Actually Return?
The register function is the core of React Hook Form. You pass it a field name and it hands back everything an input needs to participate in the form. But what exactly does it return?
const { register } = useForm();
console.log(register("email"));
// { onChange: fn, onBlur: fn, ref: fn, name: "email" }
Four properties: onChange, onBlur, ref, and name. When you spread these onto an input, RHF takes control of that field. You can destructure them if you want to see them individually:
const { onChange, onBlur, ref, name } = register("email");
console.log(name); // "email"
console.log(ref); // function ref(instance) { ... }
console.log(onChange); // async function onChange(event) { ... }
console.log(onBlur); // async function onBlur(event) { ... }
This is how the spread pattern works -- {...register("email")} is just shorthand for applying all four of these props to the input element.
The Ref-Based Approach
Here's the thing that makes RHF different from libraries like Formik: it uses refs, not React state, to track input values.
When you spread register onto an input, the ref callback attaches a reference to the actual DOM element. RHF reads values directly from the DOM via ref.current.value instead of storing them in React state. This means typing into a field does not trigger a React re-render.
Think about why that matters. In a controlled approach (using useState or a library that stores values in state), every keystroke calls setState, which triggers a re-render of the component. If your form has 50 fields and they all live in the same component, every single keystroke re-renders all 50 fields.
RHF avoids this entirely. It only reads from the DOM when it actually needs the data -- during submission, during validation, or when you explicitly ask for values with getValues(). The rest of the time, the DOM just handles input natively without React getting involved.
This is why RHF is fast. It's not a minor optimization -- on large forms, the difference is dramatic.
Validation With Register
The register function accepts a second argument: an object of validation rules. This is just a quick preview since we'll cover validation in depth in later lessons.
<input {...register("email", { required: true })} />
<input {...register("age", { required: true, min: 18, max: 120 })} />
<input {...register("username", { required: true, minLength: 3 })} />
When a field fails validation, RHF populates the errors object (which we'll cover in the next lesson). For now, just know that register is where you attach rules to fields.
Different Input Types
The register function works with all standard HTML form elements. Here's how it handles each one.
Text Inputs
The simplest case. Spread register and you're done.
<input {...register("name")} />
<input type="email" {...register("email")} />
<input type="password" {...register("password")} />
Number Inputs
Number inputs have a gotcha. By default, HTML inputs return strings even when type="number". If you want the value as an actual number in your form data, use valueAsNumber:
<input type="number" {...register("age", { valueAsNumber: true })} />
Without valueAsNumber, submitting would give you { age: "25" } (a string). With it, you get { age: 25 } (a number). Small detail, but it saves you from sprinkling parseInt calls everywhere.
Select
Selects work exactly like text inputs. The selected option's value is what RHF captures.
<select {...register("role")}>
<option value="">Select a role...</option>
<option value="developer">Developer</option>
<option value="designer">Designer</option>
<option value="manager">Manager</option>
</select>
Textarea
Same pattern. Spread and go.
<textarea {...register("bio")} />
Checkbox
Checkboxes are slightly different. Instead of capturing a string value, RHF returns a boolean -- true when checked, false when unchecked.
<input type="checkbox" {...register("terms")} />
Make sure your defaultValues reflect this. If you have a checkbox, its default should be false, not an empty string.
Complete Example
Here's a full form that brings all of these input types together:
import { useForm } from "react-hook-form";
function RegistrationForm() {
const { register, handleSubmit } = useForm({
defaultValues: {
name: "",
email: "",
age: null,
role: "",
bio: "",
terms: false,
},
});
const onSubmit = (data) => {
console.log(data);
// {
// name: "Jane",
// email: "jane@example.com",
// age: 28,
// role: "developer",
// bio: "I build things.",
// terms: true
// }
};
return (
<form onSubmit={handleSubmit(onSubmit)}>
<div>
<label htmlFor="name">Name</label>
<input id="name" {...register("name")} />
</div>
<div>
<label htmlFor="email">Email</label>
<input id="email" type="email" {...register("email")} />
</div>
<div>
<label htmlFor="age">Age</label>
<input id="age" type="number" {...register("age", { valueAsNumber: true })} />
</div>
<div>
<label htmlFor="role">Role</label>
<select id="role" {...register("role")}>
<option value="">Select a role...</option>
<option value="developer">Developer</option>
<option value="designer">Designer</option>
<option value="manager">Manager</option>
</select>
</div>
<div>
<label htmlFor="bio">Bio</label>
<textarea id="bio" {...register("bio")} />
</div>
<div>
<label>
<input type="checkbox" {...register("terms")} />
I agree to the terms
</label>
</div>
<button type="submit">Register</button>
</form>
);
}
export default RegistrationForm;
Every field is uncontrolled. There's no useState, no onChange handlers you have to write. RHF manages all of it through refs, and the only time your component re-renders from form activity is when you explicitly subscribe to state (which we'll cover later).
Every field is uncontrolled. There's no useState, no onChange handlers you have to write. RHF manages all of it through refs, and the only time your component re-renders from form activity is when you explicitly subscribe to state — which is exactly what we'll cover next with formState and error display.