const [amount, setAmount] = useState(0);
const handleAmountChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const val = parseFloat(e.target.value);
setAmount(isNaN(val) ? 0 : val);
};
<input type="number" value={amount || ''} onChange={handleAmountChange} />
If we type 0, then 5. The field shows 05.
State said 5. The payload we'd send said 5. The screen said 05. That's a controlled
component, which is supposed to mean "the DOM always shows state", and yet here it was
showing something else.
It isn't a bug in React, and it isn't a bug in our code either. It's a deliberate
trade-off, and to understand it you need to look at two layers: the browser and
React DOM.
Layer 1: what the browser thinks "05" is
An <input type="number"> keeps a string value, like every input. The HTML spec defines
a value sanitization algorithm for number inputs: if the value isn't a valid
floating-point number, the browser sets it to the empty string.
Is "05" a valid floating-point number? The spec grammar is roughly: an optional -,
then digits, then optionally . and more digits, then optionally an exponent. Nothing
there says "no leading zeros". So "05" is perfectly valid, and the browser leaves it
alone:
input.value // "05" ← the string, as typed
input.valueAsNumber // 5 ← the parsed number
What if the user types something that isn't valid yet, like - or 1e? The browser
can't sanitize a half-typed string, so it reports:
input.value // "" ← yes, empty, even though you can see "-" on screen
input.validity.badInput // true
Remember that one. It comes back later.
Layer 2: what React does on every render
A "controlled" input works like this: after each render, React DOM compares the value
prop with the DOM node's current value, and writes the prop to the DOM if they differ.
For most inputs the comparison is a strict string comparison.
For type="number" it's different. Here's the relevant code from React DOM
(ReactDOMInput.js, React 18.2; React 19 has the same check):
if (type === 'number') {
if (
(value === 0 && node.value === '') ||
// We explicitly want to coerce to number here if possible.
// eslint-disable-next-line
node.value != value
) {
node.value = toString(value);
}
} else if (node.value !== toString(value)) {
node.value = toString(value);
}
That != is loose equality, used on purpose. Our case works out like this:
node.value = "05"; // what the user typed
value = 5; // our state
"05" != 5 // → false, because "05" is coerced to 5
React decides the DOM already shows the right thing and doesn't write to it. The
input keeps showing 05.
Why would React do that on purpose?
Imagine React used strict comparison instead. The user wants to type 1.5:
| Keystroke | node.value |
parseFloat → state |
React writes | User sees |
|---|---|---|---|---|
1 |
"1" |
1 |
— | 1 |
. |
"1." |
1 |
"1" 💥 |
1 |
5 |
"15" |
15 |
— |
15 😱 |
With strict comparison, every normal parse-on-change handler would make it impossible
to type a decimal point, and the same goes for 1.0, - or 1e3. The number is fine,
but the text representation gets rewritten underneath the user's cursor.
Loose equality says: "if the DOM's text already means the same number as the prop,
leave the user's text alone." That keeps typing smooth for 1., 1.50 and -0. The
side effect is that 05 also counts as "the same number as 5".
So the input isn't really lying. It's showing the user's text, while your state holds
the number that text means.
Proof: pass a string instead
Loose equality only coerces when one side is a number. Give React a string and it's a
plain string comparison:
<input type="number" value={amount.toString()} onChange={...} />
"05" != "5" // → true, so React overwrites the DOM with "5"
Now 05 snaps to 5. We had this exact pattern in another screen (the loan-amount editor
passes loanAmount.toString()), and there the leading zero disappears. It's the same
component type, but a different prop type, so it behaves differently.
Of course, that brings back the decimal problem. "1." != "1", so the dot gets removed
again. Neither option is free.
The bonus bug: onChange that never fires
Remember badInput? React's onChange for inputs is built on a value tracker. It only
fires when the DOM value actually changed since last time.
Start with an empty field and type -:
before: input.value === ""
after: input.value === "" (badInput, because "-" isn't a number yet)
As far as the value tracker can see, nothing changed, so your onChange doesn't
run. The user sees a - on screen that your state knows nothing about. Type -5 and
you get the event, with "-5". This explains another class of odd reports ("I typed
e and the validation didn't show").
Other things type="number" does that you probably don't want
- Mouse wheel changes the value while the input is focused and hovered. Scroll the page, and your loan amount goes from ₹50,000 to ₹49,999.
-
e,E,+,-and.are allowed anywhere, because scientific notation is a valid number. -
maxLengthis ignored. The spec only applies it to text-like input types. -
Localisation: some browsers accept
,as a decimal separator depending on the locale, sovaluechanges meaning across users. - Spinner buttons appear on desktop unless you hide them with vendor CSS.
What we did
We switched to a text input with a numeric keyboard hint:
<TextInput
type="text"
inputMode="numeric"
value={amount || ''}
onChange={onAmountChange}
/>
-
inputMode="numeric"still shows the number pad on mobile, which is the main reason people choosetype="number"anyway. - The value is a normal string, so React compares strictly and what you see is exactly what's in state.
- We handle parsing and validation ourselves (strip non-digits, clamp to the max amount), so behaviour is identical in every browser.
Use inputMode="decimal" if you need a decimal point, and keep the raw string in
state (e.g. "1.") instead of parsing on every keystroke. Parse when you submit or blur.
Takeaways
- A controlled input doesn't mean "the DOM is always a serialization of state". React DOM makes different trade-offs for different input types.
- For
type="number", React compares with loose equality on purpose, so users can type intermediate states like1.. The same rule keeps05. - Passing a number vs a string to
valuechanges the behaviour. -
input.valuecan be""while the user sees text on screen (badInput), and React'sonChangemay not fire at all in that case. - For amounts, use
type="text"+inputMode+ your own parsing.
Top comments (0)