A controlled numeric field that stores a number in state cannot represent what the user is halfway through typing — -, 1., 0.0, 1e — so each keystroke is parsed, rejected or normalised, and written back, and the cursor jumps or the character vanishes.

This is a specific instance of the ownership question in controlled vs uncontrolled forms: when state is the source of truth, state must be able to hold every string the user can legitimately pass through on the way to a valid value. A number type cannot. The fix is to keep two representations and to be precise about which one each part of the form reads.


Context and prerequisites

Typing “-0.5” into a field goes through the strings -, -0, -0., -0.5. Parse each with Number() or parseFloat() and you get NaN, -0, -0, -0.5. Write those back into a controlled input and the display becomes empty, 0, 0 and -0.5 — the user sees their minus sign disappear, then their decimal point, and gives up.

The native <input type="number"> makes this worse rather than better. When its content is not a valid floating-point number, input.value returns the empty string, so a controlled component that reads e.target.value receives "" for 1e or - and cannot tell “empty” from “incomplete”. It also varies by browser: Firefox allows any characters to be typed and reports "", Chromium blocks letters other than e, and mobile keyboards may lack a minus key entirely.

Each keystroke, parsed and written back After typing a minus sign the raw text is a minus sign, the parsed value is NaN, the naive input shows an empty field and the two-representation input shows the minus sign. After minus zero the parsed value is negative zero and the naive input shows zero without the sign. After minus zero point the naive input shows zero and loses the point. Only at minus zero point five do both agree. The two-representation input always shows exactly what was typed. Typed so far Parsed Naive display Raw-text display - NaN (empty) - -0 -0 0 -0 -0. -0 0 -0. -0.5 -0.5 -0.5 -0.5 Three of four keystrokes are destroyed by round-tripping through a number. The value is only representable at the end.

The core pattern: raw text in the field, parsed number beside it

export type NumericField = {
  raw: string;                 // exactly what the input displays — never normalised while focused
  value: number | null;        // parsed result, or null when raw is empty or incomplete
  error: string | null;
};

// Accepts every prefix of a valid decimal: "", "-", "1.", ".5", "-0.".
const PARTIAL = /^-?\d*(?:[.]\d*)?$/;

export function parseNumeric(raw: string, opts: { min?: number; max?: number; decimals?: number } = {}): NumericField {
  const trimmed = raw.trim();
  if (trimmed === "") return { raw, value: null, error: null };
  if (!PARTIAL.test(trimmed)) return { raw, value: null, error: "Enter a number, like 12 or 12.5" };
  // An incomplete prefix is not an error while typing; it simply has no value yet.
  if (trimmed === "-" || trimmed === "." || trimmed === "-.") return { raw, value: null, error: null };
  const n = Number(trimmed);
  if (opts.decimals !== undefined) {
    const places = (trimmed.split(".")[1] ?? "").length;
    if (places > opts.decimals) return { raw, value: n, error: `Use at most ${opts.decimals} decimal places` };
  }
  if (opts.min !== undefined && n < opts.min) return { raw, value: n, error: `Must be ${opts.min} or more` };
  if (opts.max !== undefined && n > opts.max) return { raw, value: n, error: `Must be ${opts.max} or less` };
  return { raw, value: n, error: null };
}

// Normalise only when the user leaves the field, and only if it parsed.
export function normaliseOnBlur(field: NumericField): NumericField {
  if (field.value === null || field.error) return field;
  // Object.is catches -0, which String() would print as "0" anyway; decide
  // explicitly rather than let it happen by accident.
  const clean = Object.is(field.value, -0) ? "0" : String(field.value);
  return { ...field, raw: clean };
}
function QuantityInput({ field, onChange }: { field: NumericField; onChange: (f: NumericField) => void }) {
  return (
    <input
      type="text"                 // not type="number": we need to see incomplete text
      inputMode="decimal"         // still brings up a numeric keyboard on mobile
      value={field.raw}           // the field always displays RAW text
      onChange={(e) => onChange(parseNumeric(e.target.value, { min: 0, decimals: 2 }))}
      onBlur={() => onChange(normaliseOnBlur(field))}
      aria-invalid={field.error ? true : undefined}
    />
  );
}

Everything that needs a number — totals, cross-field rules, the submit payload — reads field.value. Everything that renders the field reads field.raw. Nothing ever writes a number back into raw while the field has focus.


Step-by-step walkthrough

  1. Switch the element to type="text" with inputmode="decimal". You keep the numeric keyboard on phones and gain the ability to read incomplete input. Add pattern only if you also rely on native constraint validation.
  2. Store raw as the controlled value. It is the only representation that can hold every intermediate string, so it is the only safe thing to feed back into the input.
  3. Parse on every change into value and error. Treat recognised prefixes (-, .) as “no value yet”, not as errors, so the user is not scolded mid-keystroke; the timing rules in reward early, punish late decide when an error actually becomes visible.
  4. Normalise on blur, never on change. Stripping a trailing . or leading zeros is fine once the user has left; doing it while they type is exactly the bug you are fixing.
  5. Make every consumer read value. Derived totals and the payload use the parsed number and treat null as “missing”, which the schema can then reject as required.
One field, two representations, one direction of flow A keystroke updates the raw string, which the input displays unchanged. The raw string is parsed into a number or null and an error or null. Consumers such as totals, cross-field rules and the submit payload read only the parsed value. On blur, if the value parsed cleanly, the raw string is rewritten to its normal form; this is the only moment a number flows back into the text. Keystroke The browser's value after the edit. Nothing is rejected here; the text the user typed is kept. store raw (string) The controlled value of the input. Always displayed as-is while the field has focus. parse value (number | null) and error null for empty or incomplete prefixes. Totals, cross-field rules and the payload read only this. on blur Normalise raw Only if value parsed with no error. The single place a number is written back into the text.

Failure modes and edge cases

1. Locale decimal separators

A German user types 1,5. The regex above rejects it and Number("1,5") is NaN. If your audience writes commas, parse with the locale’s separator, which locale-aware number and currency inputs covers in full — including why Intl.NumberFormat can format but not parse.

2. Scroll-wheel and arrow-key changes on type="number"

If you keep type="number", a mouse wheel over a focused field silently changes its value, and a user scrolling the page edits their quantity. Either switch to text as above or blur the field on wheel:

input.addEventListener("wheel", () => input.blur(), { passive: true });

3. Large integers and identifiers

Account numbers, postcodes and card numbers are not numbers: leading zeros matter and values beyond Number.MAX_SAFE_INTEGER lose precision. Keep them as strings end to end; only quantities and amounts should ever be parsed.

4. Money as floating point

0.1 + 0.2 is 0.30000000000000004. Parse currency into integer minor units (Math.round(n * 100) after validating decimal places) or send the normalised string to a server that uses a decimal type. Never sum user-entered floats for a displayed total without rounding.

5. Programmatic updates while focused

A “use suggested amount” button that writes raw while the field is focused is fine — it is a deliberate replacement. A recalculation effect that rewrites raw from value on every render is the original bug in a new place; guard any such effect with document.activeElement !== input.

Field types that look numeric but must stay strings Quantities such as age, count and percentage should be parsed to numbers. Identifiers such as postcodes, account numbers and card numbers must stay strings because leading zeros and length matter and large values lose precision. Money should be validated for decimal places and converted to integer minor units or sent as a decimal string, never summed as floats. Parse to number Quantity, age, percentage, rating. Arithmetic is meaningful; leading zeros are not. Keep as string Postcode, account number, card number, phone. Leading zeros and length carry meaning; big values lose precision. Integer minor units Price, amount, balance. Validate decimal places, then convert to cents or send a decimal string.

Verification checklist


Frequently Asked Questions

Why not use input type="number"?

Because it reports an empty string for any content that is not yet a valid number, so a controlled component cannot distinguish “empty” from “halfway through typing -1.5”. It also has inconsistent validation across browsers and changes value on scroll. type="text" with inputmode="decimal" keeps the mobile keyboard and gives you the raw text.

Where should min and max be enforced?

In the parser that produces error, and again in the schema that validates the payload. Never clamp the raw text while the user types — turning 150 into 100 as they type the zero is as disorienting as losing the minus sign.

Does this apply to uncontrolled inputs?

Much less. An uncontrolled input keeps whatever the user types because nothing writes back into it; you parse only when you read it, usually on submit. The two-representation discipline is the price of controlling the field.


Related

← Controlled vs Uncontrolled Forms