Skip to content

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.

Arrow Software Team
Arrow Software TeamMay 24, 2026 · 4 min read
Laptop showing a form being filled out
Forms are where RUT validation lives or dies. Source: Unsplash.

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.

TypeScript
// 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.

TSX
"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.

TSX
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#

  • Controller on a native <input> is overhead with no payoff. Every keystroke triggers a render inside the Controller's scope. Use register and reach for Controller only 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 default onSubmitonBlur cadence is the right choice for a RUT field; the blur format already triggers a fresh validation pass.

  • Forgetting shouldValidate: true leaves 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 reads 12.345.678-5. Always pair the blur setValue with the validation flag.

Further reading#

rut.ts

MIT License © 2026 Arrow Software