Chilean RUT in React Hook Form: register vs Controller
Validate a Chilean RUT in React Hook Form with rut.ts and Zod — when to pick Controller, format on blur with `setValue`, and keep `formState.errors` fresh.
A Chilean RUT field in React Hook Form comes down to three RHF-specific decisions: whether to use register or Controller, how to format on blur without making the error state go stale, and how to keep formState.errors reading from the display-normalized value rather than the raw input. The Zod schema layer is covered separately in Validate Chilean RUT in Next.js Server Actions with Zod — this post zooms in on the RHF mechanics. Both validate() and format() ship from rut.ts, the zero-dependency, TypeScript-native RUT library — no extra hook helper, no transitive packages.
Should I use register or Controller for a RUT field?#
Default to register on a plain <input> — it is cheaper (no per-keystroke render), composes with a blur formatter through the second-argument options, and is the right tool for the vast majority of RUT fields. Reach for Controller only when the rendered component owns its own value/onChange contract: a masked input from a UI kit, a third-party component, or the progressive RUT input. Controller over a native <input> is overhead with no payoff.
The schema, in one snippet#
The schema below is the minimum needed to make the form runnable. The reasoning behind strict: true and the placeholder block lives in the Server Actions post and Hardening RUT acceptance. Importing both schemas from the same file means the client and a server action see the same definition.
// app/checkout/schema.ts
import { z } from "zod";
import { validate } from "rut.ts";
export const checkoutSchema = z.object({
rut: z
.string()
.trim()
.min(1, "RUT is required")
.refine((v) => validate(v, { strict: true }), {
message: "Enter a valid Chilean RUT",
}),
});
export type CheckoutValues = z.infer<typeof checkoutSchema>;register vs Controller#
register is the right choice for a plain <input>. It returns the four props RHF needs (name, ref, onChange, onBlur), spreads onto the input, and leaves enough headroom in the second argument to attach the blur formatter. No re-renders are triggered on keystroke, so the field is essentially free.
"use client";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { format } from "rut.ts";
import { checkoutSchema, type CheckoutValues } from "./schema";
export function CheckoutForm() {
const { register, handleSubmit, setValue, formState } =
useForm<CheckoutValues>({ resolver: zodResolver(checkoutSchema) });
return (
<form onSubmit={handleSubmit((data) => console.log(data))}>
<label htmlFor="rut">RUT</label>
<input
id="rut"
inputMode="numeric"
autoComplete="off"
{...register("rut", {
onBlur: (e) => {
const f = format(e.target.value, { throwOnError: false });
if (f) setValue("rut", f, { shouldValidate: true });
},
})}
/>
{formState.errors.rut ? (
<span role="alert">{formState.errors.rut.message}</span>
) : null}
<button type="submit">Continue</button>
</form>
);
}Controller is the right choice when the field is rendered by something that owns its own value/onChange contract: a masked input from a UI kit, a third-party component, or the progressive RUT input that manages its own caret position. The cost is an extra render scope around the field; the benefit is that field.value and field.onChange flow through whatever interface the component expects.
import { Controller } from "react-hook-form";
import { ProgressiveRutInput } from "./ProgressiveRutInput";
<Controller
control={control}
name="rut"
render={({ field, fieldState }) => (
<>
<ProgressiveRutInput
value={field.value ?? ""}
onChange={field.onChange}
onBlur={field.onBlur}
/>
{fieldState.error ? (
<span role="alert">{fieldState.error.message}</span>
) : null}
</>
)}
/>;Default to register. Reach for Controller only when the component will not accept the props that register returns.
Format on blur, then revalidate#
The blur handler runs format() with { throwOnError: false } so a partial or invalid value returns null instead of throwing. The if (f) guard is load-bearing: writing setValue("rut", null) would replace whatever the user typed with a coerced "null" string and put the form into a state the schema cannot describe.
setValue("rut", f, { shouldValidate: true }) is the part that is easy to forget. Without it, RHF stores the formatted value but does not re-run the resolver, so an error from the pre-format input lingers on a field that now contains a perfectly valid RUT. With it, the resolver runs against the canonical string and the error clears in the same microtask as the format.
Pitfalls#
-
Controlleron a native<input>is overhead with no payoff. Every keystroke triggers a render inside theController's scope. Useregisterand reach forControlleronly when the rendered component has a non-RHF-shaped API. -
Validating on every keystroke (e.g.
useForm({ mode: "onChange" })) puts errors in front of users who are mid-typing. The defaultonSubmit→onBlurcadence is the right choice for a RUT field; the blur format already triggers a fresh validation pass. -
Forgetting
shouldValidate: trueleaves stale errors visible after the formatter has already made the value valid. The user sees "Enter a valid Chilean RUT" on a field that now reads12.345.678-5. Always pair the blursetValuewith the validation flag.
Further reading#
- Install rut.ts —
pnpm add rut.ts, zero dependencies - Quick start — five minutes from install to a working RUT form
- Validate Chilean RUT in Next.js Server Actions with Zod — the canonical schema + trust-boundary explanation
- A progressive RUT input for React — the natural
Controllerpartner - Hardening RUT acceptance: strict mode and safe processing
validate()reference ·format()reference