Transform and parse
Convert between input strings and the types your form stores.
Native inputs always hold strings. Your form values often need other types: a number for a quantity, null for an empty optional field, an array for a tag list. This page shows where to convert between the two, and which option fits each case.
Choose where to convert
| Need | Use |
|---|---|
A required number from <input type="number"> | valueAsNumber: true in register |
A date from <input type="date"> | valueAsDate: true |
| Any other conversion on a native input | setValueAs |
| A controlled component that displays text | Keep the text in local state and store the parsed value with useController |
| Conversion rules shared with the server | A schema transform, such as z.coerce with Zod |
Native inputs
valueAsNumber, valueAsDate, and setValueAs run when React Hook Form reads the input. The input keeps the raw text the user typed, so partial input like - or 1. is never rewritten.
valueAsNumberreturnsNaNfor an empty or non-numeric input. Check for it withNumber.isNaNorNumber.isIntegerinvalidate.requireddoes not catch it, becauseNaNis a value.setValueAsreceives the input's text, and also your default value when the field registers. Handle both types, as thetagsfield in the example does.setValueAsis ignored whenvalueAsNumberorvalueAsDateis set.- These options apply to native inputs only. Values set with
setValueare stored as given.
Controlled components
A controlled input shows field.value. If you format that number back to text on every keystroke, partial input breaks: typing 12.5 shows 12 after the dot, and the next key gives 125. Keep the text the user typed in local state and store the parsed value in the form. The PriceInput component below does this.
The local text initializes once. If you call reset or setValue on that field, also update the text, or remount the component with a new key.
import { useState } from "react"import { useController, useForm, type Control } from "react-hook-form"type FormValues = {quantity: numberdiscount: number | nullprice: number | nulltags: string[]}// For controlled components: keep what the user typed as text, and store// the parsed number (or null) in the form.function PriceInput({ control }: { control: Control<FormValues> }) {const { field, fieldState } = useController({control,name: "price",rules: {validate: (value) =>value === null || value > 0 || "Enter a price above 0.",},})const [text, setText] = useState(field.value?.toString() ?? "")return (<><label htmlFor="price">Price</label><inputid="price"inputMode="decimal"name={field.name}ref={field.ref}onBlur={field.onBlur}value={text}onChange={(event) => {setText(event.target.value)const trimmed = event.target.value.trim()field.onChange(trimmed === "" ? null : Number(trimmed))}}/>{fieldState.error && <p role="alert">{fieldState.error.message}</p>}</>)}export default function OrderForm({onSubmit,}: {onSubmit: (values: FormValues) => void}) {const {register,control,handleSubmit,formState: { errors },} = useForm<FormValues>({defaultValues: { quantity: 1, discount: null, price: null, tags: [] },})return (<form noValidate onSubmit={handleSubmit(onSubmit)}><label htmlFor="quantity">Quantity</label><inputid="quantity"type="number"{...register("quantity", {valueAsNumber: true,validate: (value) =>(Number.isInteger(value) && value >= 1) || "Enter a whole number.",})}/>{errors.quantity && <p role="alert">{errors.quantity.message}</p>}<label htmlFor="discount">Discount (%), optional</label><inputid="discount"inputMode="decimal"{...register("discount", {setValueAs: (value: string | number | null) =>value === "" || value === null ? null : Number(value),validate: (value) =>value === null ||(value >= 0 && value <= 100) ||"Enter a number from 0 to 100.",})}/>{errors.discount && <p role="alert">{errors.discount.message}</p>}<PriceInput control={control} /><label htmlFor="tags">Tags (comma separated)</label><inputid="tags"{...register("tags", {setValueAs: (value: string | string[]) =>Array.isArray(value)? value: value.split(",").map((tag) => tag.trim()).filter(Boolean),})}/><button type="submit">Save</button></form>)}
For valid input, handleSubmit receives values such as { quantity: 3, discount: 12.5, price: 12.5, tags: ["a", "b", "c"] }. An empty discount or price is stored as null.
Schema transforms
With a resolver, the schema can parse values for you. The input type (what the inputs hold) and the output type (what the schema returns) then differ. Pass both to useForm, and handleSubmit receives the parsed output:
import { useForm } from "react-hook-form"import { zodResolver } from "@hookform/resolvers/zod"import { z } from "zod"const schema = z.object({age: z.coerce.number().int().min(18, "You must be 18 or older."),email: z.string().trim().toLowerCase().email("Enter a valid email."),})type FormInput = z.input<typeof schema> // what the inputs holdtype FormOutput = z.output<typeof schema> // what handleSubmit receivesexport default function SignupForm({onSubmit,}: {onSubmit: (values: FormOutput) => void}) {const {register,handleSubmit,formState: { errors },} = useForm<FormInput, unknown, FormOutput>({resolver: zodResolver(schema),defaultValues: { age: "", email: "" },})return (<form noValidate onSubmit={handleSubmit(onSubmit)}><label htmlFor="age">Age</label><input id="age" inputMode="numeric" {...register("age")} />{errors.age && <p role="alert">{errors.age.message}</p>}<label htmlFor="email">Email</label><input id="email" type="email" {...register("email")} />{errors.email && <p role="alert">{errors.email.message}</p>}<button type="submit">Sign up</button></form>)}
The form stores "30" and " A@B.CO ", and handleSubmit receives { age: 30, email: "a@b.co" }. Rules run on the parsed values, so min(18) checks the number, not the string. Use the same schema on the server to parse the request.