A progressive RUT input for React (no cursor jumps)
Format a Chilean RUT on every keystroke with rut.ts `incremental` mode — cursor-stable controlled input, no manual mask state, no extra dependencies.
The obvious way to format a Chilean RUT as the user types is to intercept onChange, call format(), and write the result back into the input. In practice, replacing the value on every keystroke forcibly moves the caret to the end of the string — a user correcting a digit in the middle of the number feels like they are teleporting on every character. Password managers compound the problem: they fire synthetic onChange events, which a naive setter loops on indefinitely. format(value, { incremental: true }) paired with a small caret-correction step makes the input behave the way users expect, with the formatted result growing naturally as digits arrive rather than snapping the cursor to the wrong position.
How do I format a Chilean RUT as the user types in React?#
Use format(value, { incremental: true, throwOnError: false }) from rut.ts inside the input's onChange, capture the caret position before the state update, and restore it inside requestAnimationFrame after the formatted value commits. incremental mode is the only RUT-library helper that emits cursor-friendly partial output — no manual mask state machine, no extra masking dependency, no character-by-character routing.
Why this is tricky#
The straightforward implementation — call format() on every onChange, assign the result to the input's value — immediately runs into a browser limitation. React controlled inputs set the value attribute on every render, and setting value resets the cursor to the end of the string. For a user correcting a digit in the middle of a number, every keystroke feels like teleporting to the end of the field. The experience is disorienting enough that many teams give up on live formatting and fall back to formatting only on blur.
The problem is deeper than a simple cursor reset. Formatting a RUT changes the string length. When the formatter inserts dots and a hyphen, the formatted string is longer than what the user typed. If you read the caret position before the update and write the same position back afterward, the caret lands in the wrong place — offset by the number of punctuation characters added or removed. A correct implementation must compute the delta between the formatted length and the raw length and shift the caret by that amount.
Password managers and autofill add a third layer of difficulty. When a manager fills a field, it often fires an onChange event with the complete value, then fires it again as internal state settles. A naive handler can trigger an infinite update loop: the manager fires, the handler formats and writes, the write triggers another event, and so on. autoComplete="off" reduces the frequency, but reading caret state before any state update is the more durable protection.
The incremental mode of format()#
format(value, { incremental: true }) returns a partial formatted string that grows naturally as digits arrive. The punctuation is inserted progressively: 1 stays 1, 1234 becomes 1.234, 12345 becomes 12.345, and eventually 12345678-5 arrives at 12.345.678-5. At no point does the formatter insert a dot or hyphen the user has not yet "earned." That progressive behavior is exactly what live formatting requires — the string at any intermediate step is visually coherent and the caret stays roughly where the user placed it.
If your product stores RUTs in the hyphen-only canonical form, pass dots: false alongside incremental: true. The result grows as 12345678-5 instead of 12.345.678-5, and the caret math simplifies because there are fewer inserted characters to account for.
When you need just the digits — for a backend call or a checksum pre-check — pair the formatted value with clean(value, { throwOnError: false }). clean() strips formatting and returns the raw digit string, or null if the input cannot be parsed. throwOnError: false ensures the component never throws inside an onChange handler.
One important caveat: incremental formatting is entirely cosmetic. It does not verify the Modulo 11 checksum. A partially typed RUT looks formatted, but the value may not yet be a valid identity number. Validation is a separate concern covered in the next section.
A reusable controlled input#
The component below accepts value and onChange as props, making it a drop-in inside React Hook Form's Controller, a custom form context, or a plain useState call. All formatting logic is isolated inside the component; the parent receives already-formatted strings and never needs to call format() itself.
The key insight is that requestAnimationFrame must be used to restore the caret rather than setting it synchronously inside the handler. React batches state updates and defers DOM commits; if you call setSelectionRange inside onChange, the correction runs against the old DOM and is immediately overwritten when React commits the new value. Wrapping it in requestAnimationFrame schedules the correction for the next frame, after the DOM reflects the formatted string.
"use client";
import { useRef, type ChangeEvent } from "react";
import { format } from "rut.ts";
type Props = {
value: string;
onChange: (next: string) => void;
};
export function RutInput({ value, onChange }: Props) {
const ref = useRef<HTMLInputElement>(null);
function handleChange(e: ChangeEvent<HTMLInputElement>) {
const raw = e.target.value;
const caret = e.target.selectionStart ?? raw.length;
const formatted =
format(raw, { incremental: true, throwOnError: false }) ?? raw;
const delta = formatted.length - raw.length;
onChange(formatted);
requestAnimationFrame(() => {
const el = ref.current;
if (!el) return;
const pos = Math.max(0, caret + delta);
el.setSelectionRange(pos, pos);
});
}
return (
<input
ref={ref}
value={value}
onChange={handleChange}
inputMode="numeric"
autoComplete="off"
aria-label="RUT"
/>
);
}Reading selectionStart before calling onChange is essential. The moment React re-renders, the input's selection state is no longer reliable. Capturing it in the first line of the handler, before any asynchronous work, guarantees the correct pre-update caret position to shift from.
The Math.max(0, caret + delta) guard prevents the position from going negative. When the formatter shortens the string — for example, when a user deletes the last digit in a group and the preceding dot is removed — the delta is negative, and without the clamp the position would underflow. ref.current is checked before calling setSelectionRange because the component could unmount between the onChange event and the animation frame callback.
Validation comes later#
incremental formatting makes the input comfortable to type in. It does not make the RUT valid. A half-typed value like 12.345 is formatted correctly by the progressive formatter, but validate("12.345", { strict: true }) returns false because the checksum cannot be verified on an incomplete number.
The acceptance check belongs on submit or on blur, not inside onChange. Firing validate() on every keystroke shows error messages while the user is actively entering a valid number — friction that leads to abandoned forms. Run validate(value, { strict: true }) once when the field loses focus or the form is submitted.
The RutInput component above slots directly into the Controller pattern from the companion post on validating Chilean RUTs with React Hook Form and Zod, where validate() is wired into a Zod schema and run through zodResolver.
Further reading#
- Install rut.ts —
pnpm add rut.ts, zero dependencies - Quick start — five-minute path from install to a working RUT form
format()reference — includingincrementalanddotsoptionsclean()reference- Chilean RUT in React Hook Form: register vs Controller