skip to content

A React controlled text input reformats the value in onChange — inserting spaces into a card number — and the caret jumps to the end whenever the user edits in the middle. What causes that, and how do you fix it?

level: seniorimportance: should knowfreq 30%

answer

  1. the value is rewritten every keystroke
  2. setting value moves the cursor to the end
  3. offsets are saved, not positions
  4. count significant characters, not indexes
  5. format on blur if you can

basics

~20 s

React writes the state value back onto the DOM node, and replacing an input's value collapses the text cursor to the end. Once formatting inserts characters, the restored caret offset no longer matches the same spot, so it lands at the end.

solid answer

~60 s

With a controlled input, the string the user typed never reaches the screen directly — it goes to state, and React writes state back onto the node on the next commit. Assigning a different string to `input.value` makes the browser move the caret to the end of the new text. React does try to restore the selection offsets it recorded for the focused element, but those are plain character offsets: if your formatter inserted separators before the caret, the same number now points somewhere else, and with reformatting on every keystroke it reads as the caret jumping to the end. The robust fixes are ordered by cost: format on blur or at submit and keep raw characters in state while typing; or, if it must format live, compute where the caret belongs — count the significant characters before it, reformat, walk forward to the same count — and call `setSelectionRange` in a layout effect after the value has committed. Reaching for a maintained input-mask library is a legitimate answer, because the edge cases (deletion, paste, selection replacement) are many.

code

jsx · 41 lines
jsx
import { useLayoutEffect, useRef, useState } from 'react';

export function CardNumberInput() {
  const [card, setCard] = useState('');
  const inputRef = useRef(null);
  const caretRef = useRef(null);

  function handleChange(e) {
    const el = e.target;
    const digitsBefore = el.value.slice(0, el.selectionStart).replace(/\D/g, '').length;
    const digits = el.value.replace(/\D/g, '').slice(0, 16);
    const formatted = digits.replace(/(.{4})(?=.)/g, '$1 ');

    let index = 0;
    let seen = 0;
    while (index < formatted.length && seen < digitsBefore) {
      if (/\d/.test(formatted[index])) seen += 1;
      index += 1;
    }

    caretRef.current = index;
    setCard(formatted);
  }

  useLayoutEffect(() => {
    if (caretRef.current != null && inputRef.current) {
      inputRef.current.setSelectionRange(caretRef.current, caretRef.current);
      caretRef.current = null;
    }
  });

  return (
    <input
      ref={inputRef}
      value={card}
      onChange={handleChange}
      inputMode="numeric"
      autoComplete="cc-number"
    />
  );
}

go deeper

for a junior

Recall that a controlled input's displayed text comes from state on every render, so anything your handler changes about the typed string is what the user ends up seeing.

for a middle

Explain the mechanism: assigning a new string to an input's value moves the caret to the end, and restored character offsets stop matching once formatting inserts or removes characters.

for a senior

Diagnose it from the symptom and choose deliberately — format on blur, or compute the caret from significant-character counts and restore it in a layout effect before paint.

for a principal

Weigh building a mask against adopting a maintained one, and set the standard for input handling across the product so paste, deletion, IME composition and autofill are covered once rather than per form.

## Why a controlled input can move the caret at all An uncontrolled input never has this problem: the browser inserts the character where the caret is, and the caret advances by one. Nothing overwrites the value, so nothing disturbs the selection. A controlled input takes a detour. The keystroke fires `onChange`, your handler derives a new string, state updates, React re-renders, and during commit React sets the DOM node's `value` to the state value. Assigning to `value` is a value-replacement operation, and the HTML specification says that when it changes the control's value it moves the text entry cursor to the end of the text. React mitigates this: for the currently focused element it records `selectionStart` and `selectionEnd` before mutating the DOM and restores them afterwards, which is why an ordinary controlled input — where the state value equals what the user typed — behaves normally even though the value is technically rewritten each time. ## Where formatting breaks the mitigation The saved selection is a pair of integer offsets, and offsets only mean anything relative to a specific string. Type into `4111 1111` at position 6 and your formatter produces `4111 11X11` — every character after the insertion point shifted, and a separator may have been added ahead of the caret as well. Restoring offset 6 now points at a different character than the one the user was working on. Under repeated insertions the drift compounds and the practical symptom users report is "the cursor jumps to the end". Deletion is worse. The user presses Backspace on a separator, the formatter re-inserts it, and the value comes back byte-for-byte identical to what it was before the keystroke — the deletion appears to do nothing, and the caret is now one position off. Paste and select-then-type replace a whole range at once and blow past any naive offset arithmetic. ## Fix 1 — do not format while typing The cheapest correct answer, and often the right one. Keep the raw characters in state, render them as typed, and format on blur or at submit: ```jsx const [card, setCard] = useState(''); <input value={card} onChange={(e) => setCard(e.target.value.replace(/\D/g, ''))} onBlur={() => setCard((v) => v.replace(/(.{4})/g, '$1 ').trim())} /> ``` Stripping characters that the user cannot type anyway (non-digits) does not shift anything before the caret, so it is safe; inserting separators does, so it waits until focus leaves. You give up live grouping, and for many products that trade is worth making. ## Fix 2 — restore the caret yourself When live formatting is a requirement, stop thinking in raw offsets and think in *significant characters*. Count how many non-separator characters precede the caret, format the string, then walk forward through the formatted string until you have passed that many significant characters — that index is where the caret belongs. ```jsx const ref = useRef(null); const caret = useRef(null); function handleChange(e) { const el = e.target; const digitsBefore = el.value.slice(0, el.selectionStart).replace(/\D/g, '').length; const digits = el.value.replace(/\D/g, '').slice(0, 16); const formatted = digits.replace(/(.{4})(?=.)/g, '$1 '); let index = 0, seen = 0; while (index < formatted.length && seen < digitsBefore) { if (/\d/.test(formatted[index])) seen++; index++; } caret.current = index; setCard(formatted); } useLayoutEffect(() => { if (caret.current != null && ref.current) { ref.current.setSelectionRange(caret.current, caret.current); caret.current = null; } }); ``` Two details make this work. The desired position is stashed in a ref rather than state, because it is not rendered and should not cause an extra render. And the restore runs in `useLayoutEffect`, which fires after React has written the new value to the DOM but before the browser paints — in a plain `useEffect` the user can see one frame with the caret in the wrong place. ## Fix 3 — use a maintained mask library The list of cases a production mask must survive is long: paste, drag-and-drop text, select-and-replace, Backspace versus Delete on a separator, IME composition for languages that compose characters before committing them, autofill, and undo. Saying "I would reach for a maintained masking input rather than re-derive all of that" is a strong senior answer, provided you can still explain the mechanism the library is handling for you. ## Related symptoms worth recognising If state updates never reach the input at all — a handler that filters the value and returns the old string — the field appears frozen, because the committed value overwrites what the browser optimistically displayed. And if state is updated asynchronously somewhere far from the input, characters typed in the meantime can be discarded when the stale value commits. Both share the same root: with a controlled input, the screen is state, and anything that makes state disagree with the user's intent is visible immediately.

  • Why restore the caret in useLayoutEffect rather than useEffect?
    Because `useLayoutEffect` runs after React has written the new value to the DOM but before the browser paints, so the corrected caret is the first thing the user sees. A `useEffect` runs after paint, which leaves one visible frame with the caret in the wrong spot — perceptible as a flicker when you are typing quickly.
  • Backspacing over a separator seems to do nothing. What is happening?
    The keystroke removes the separator, the formatter puts it straight back, and the resulting string is identical to the previous value. Nothing changed, so the field looks stuck while the caret drifts. Handle it explicitly: when a deletion lands on a separator, also drop the significant character next to it, so a Backspace always removes something the user perceives.
  • Would switching the field to uncontrolled remove the problem entirely?
    It removes React's value rewrite, so plain typing keeps a stable caret — but only if you also stop formatting live. The moment you write a formatted string onto the node yourself, the browser collapses the caret to the end for the same reason. Uncontrolled changes who does the assignment, not what assignment does.

saying these in an interview costs you the question

  • Blames React's re-render speed rather than the value assignment
  • Thinks debouncing the state update fixes the caret
  • Assumes the saved offset points at the same character after formatting
  • Restores the caret in a plain effect and ships a visible flicker
  • Ignores deletion and paste while only handling single keystrokes

context