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.
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
- Switch the element to
type="text"withinputmode="decimal". You keep the numeric keyboard on phones and gain the ability to read incomplete input. Addpatternonly if you also rely on native constraint validation. - Store
rawas 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. - Parse on every change into
valueanderror. 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. - 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. - Make every consumer read
value. Derived totals and the payload use the parsed number and treatnullas “missing”, which the schema can then reject as required.
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.
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.