Shipmind Labs

A money input that regroups on every keystroke eats a digit

· 9 min read

A money field that reformats its own text while someone is still typing will swallow a character sooner or later. The user adds a digit, the field rewrites the string to move a group separator, the browser leaves the caret at the end of the new value, and the next keystroke lands somewhere other than where the person was looking. The formatter is almost never where the bug lives. The timing is: the text got rewritten while the value was still moving.

We build and operate money paths (payment services, hosted payment pages under a merchant's own branding, ledgering and reconciliation), and this is the most common defect we find in an otherwise careful checkout or payout form. A better regular expression will not fix it. What fixes it is a rule about when the field is allowed to touch the text at all, plus a policy for the two moments when a rewrite really is unavoidable.

Where the character goes#

Here is the implementation almost everyone writes first, a controlled input that reformats on every change event.

tsx
function NaivePrice() {
  const [text, setText] = useState("");
  return (
    <input
      value={text}
      onChange={(event) => {
        const digits = event.target.value.replace(/[^\d.]/g, "");
        setText(new Intl.NumberFormat("en-US").format(Number(digits)));
      }}
    />
  );
}

The float is the famous problem with this code, and the least interesting one here. The other two are what the user actually feels.

The decimal point cannot be typed. 12. goes through Number and comes back as 12, formats as 12, and the dot disappears between the keypress and the render. The person presses it again and gets the same nothing. Nobody files this as a caret bug, but it is the same bug: the text got rewritten before it was finished.

The caret goes to the end. Writing a new string into the element's value discards the selection the browser had just maintained, and nothing in the handler puts it back. Editing in the middle of a number becomes impossible, because the next character appends at the end instead. And when the rewrite inserts or removes a group separator, every offset the user was aiming at shifts underneath them: a backspace aimed at a digit takes the separator, the formatter re-derives the string from what is left, and a digit is gone for good.

So the field fights the person entering the value, on every keystroke, in exchange for a cosmetic benefit that nobody needs until the value is final.

Entry is read-only; grouping belongs to blur#

The rule we settled on is that the field does not rewrite text while the value is moving. Grouping gets applied on blur, once it has stopped. That is the policy encoded in the state machine behind amountfield, our React money input, and the comment in src/field.ts carries the whole argument. Regrouping mid-typing is what makes a field jump the caret and eat a digit, so we leave that to blur.

The state the field carries is deliberately wider than a string and a value:

typescript
export type FieldState = {
  /** What the input element shows. Always what the person typed, until blur. */
  readonly text: string;
  /** The exact amount, when the text is a complete one. */
  readonly money: Money | null;
  /** Why there is no amount. Null while the field is simply incomplete. */
  readonly problem: ParseFailure | null;
  /** True when the text is a prefix of a valid amount rather than wrong. */
  readonly incomplete: boolean;
  /** Where the caret belongs in `text`, after whatever rewrote it. */
  readonly caret: number;
};

text and money are separate fields because they are separate jobs. The text belongs to the person typing. The amount is exact minor units held in parallel, and the two do not need to agree on presentation at any point during entry:

typescript
const eur = { currency: "EUR", locale: "en-US" };

let state = typed("1234.5", eur);
// state.text  === "1234.5"      <- untouched, mid-entry
// state.money                   <- 123450 minor units, already exact

state = blurred(state, eur);
// state.text  === "1,234.50"    <- the one rewrite we chose

The value was never ambiguous while the text sat half written. Deferring the formatting costs nothing in correctness, because the amount never came from the pretty string in the first place.

When a rewrite is unavoidable, count what the person is aiming at#

Two rewrites survive the rule. Blur applies grouping, and a paste has to be cleaned up, because a money field cannot hold its own currency symbol beside the number. We keep sanitising as narrow as it can be:

typescript
export function sanitise(text: string, options: FieldOptions): string {
  const currency = normalizeCurrency(options.currency);
  const locale = options.locale ?? "en-US";

  let cleaned = text.replace(MINUS, "-");
  for (const mark of marksFor(currency, locale)) cleaned = cleaned.split(mark).join("");
  cleaned = cleaned.replace(new RegExp(currency, "gi"), "");

  return cleaned.replace(/^\s+/, "");
}

A field that then drops the caret at the end is worse than one that never reformats at all, because now the text moves and the user has no model of when. So we make the caret policy explicit: the digits, the sign and the decimal point are what the person is aiming at, while grouping, spaces and symbols move around them. The caret goes back after the same count of the former. In practice:

  • 1234.5^ on blur becomes 1,234.5^0, not 1,234.50^. Six aimed-at characters before the caret, still six after.
  • ^1234 becomes ^1,234. Zero before the caret, so it stays in front of the number once the number grows a separator.
  • € 1.234,^56 pasted into a EUR field becomes 1.234,^56. The symbol and the no-break space behind it vanish in front of the caret, and the caret comes back with them gone.

This is also why useAmountField asks you to spread inputProps rather than pick fields out of it. The ref it carries is how the caret actually gets restored after React has written the new value into the DOM node:

tsx
function PriceField() {
  const field = useAmountField({ currency: "EUR", locale: "de-DE" });
  return (
    <>
      <input {...field.inputProps} />
      {field.problem && <span role="alert">{field.problem}</span>}
    </>
  );
}

Not finished yet has to become a real state#

Leaving the text alone during entry forces a distinction that a reformat-on-keystroke field never has to make. 12. is twelve on the way to 12.00. 12,5 in a de-DE field is twelve fifty with the last digit still missing. 1 2 in fr-FR is a group that has not reached three digits. A lone - is on its way to -5. None of these parse, and none of them are mistakes.

That is what incomplete is for, and why it sits apart from problem. The first means the text is a prefix of a valid amount, the second means it is wrong. aria-invalid in inputProps follows problem and not incomplete, so the field does not announce itself as invalid between the . and the 5 of 12.50.

The refusals are still refusals. 12.345 in a two-decimal currency comes back too-many-decimals. 12.34 typed into a de-DE field comes back bad-grouping rather than being read as 1234.00, because the dot is a decimal point written with the wrong separator far more often than it is a thousands mark on a two-digit number. $12.34 pasted into a EUR field keeps its dollar sign (only the field's own currency is stripped) and gets refused as not-a-number rather than quietly taken for euros. A field that leaves the text alone mid-entry can afford to be strict at the edges, because strictness no longer lands on someone who is simply still typing.

The caret is testable because the logic is not in the component#

All of this is a pure function, and the hook is a thin wrapper over it. That is not architectural taste. An input's awkward cases (a half-typed 12., a pasted 1 234,56, a caret in the middle of the number) are exactly what nobody tests when the logic only exists inside a component, and the caret is the first thing to rot in a refactor, probably because nothing fails loudly when it moves.

With the state machine outside React, the caret is an assertion like any other. typed takes the position the input element left the caret at and returns where it belongs:

typescript
const eur = { currency: "EUR", locale: "en-US" };

const pasted = typed("\u20ac1234", eur, 3);
// pasted.text  === "1234"   the field's own symbol is stripped
// pasted.caret === 2        two digits were in front of it, two still are

const grouped = blurred(typed("1234.5", eur, 6), eur);
// grouped.text  === "1,234.50"
// grouped.caret === 7        after the "5", not after the appended "0"

Those two cases are the regression test for the entire argument of this post, and they run in milliseconds without a DOM.

What it costs to run#

Three things, and they are worth naming before you adopt the pattern.

You hold a caret in state and own a ref. That is unavoidable if you intend to rewrite text at all; the alternative is not holding it and losing it.

Blur becomes semantically meaningful. A form that submits without the field losing focus has to read field.money, which is exact from the first complete keystroke, rather than parsing the displayed text. Resets come through setMoney rather than by writing a formatted string in.

And you carry three states in your UI instead of two: valid, wrong, and not finished. Most of the design work in a calm money field sits in that third one.

The formatting itself is cheap. Intl formatters and the separators for a locale are built once and cached per key, and grouping now happens on blur rather than on every keystroke, so the field does strictly less work than the version that eats digits.

None of this makes a money field interesting. It makes it quiet, and quiet is the whole specification: the number the user typed is the number that is there, and the caret is where they left it.

Was this useful?

Building something similar?

or email hello@shipmindlabs.com