A simple compromise: Input number formatting in React

Big numbers are hard to read, which is why they are often split into groups of digits when displayed to make them easier for humans to parse visually.

Five million might be displayed as 5,000,000 instead of 5000000. But this way of displaying a number poses a problem when a user is asked to input or modify a number displayed this way as part of a HTML form control. There isn’t any native support for grouping digits as part of a HTML <input> tag.

In this article I will demonstrate a simple compromise that solves this problem neatly using React, without resorting to using an external library.

The problem of parsing and locale

A <input type="number" /> HTML tag is naturally meant for inputting numbers and is the first choice when requiring users to enter numbers. But it has no way to control how the value is displayed within the control itself. For very big numbers it will just end up looking like a mess of digits.

One solution to the problem would be to not use number inputs at all, and instead resort to a normal text input. A normal input like that could be adapted to contain the grouping separator characters. But this requires parsing the value the user enters back into an actual number at some point which is quite a tricky problem for a number of reasons. Different users might be in different countries and thus have different expectations on which character should separate groups of digits, introducing ambiguity around exactly what number a user meant.

For instance, the value 5,550 might mean “five thousand and five hundred and fifty” in an American context but “Five point five hundred and fifty” in a Swedish context (my native locale), where a comma is used as the decimal separator and not as a grouping digit.

Inputting numbers as freely-formatted (more or less) text strings and then attempting parsing is clearly not an ideal solution.

As a quick aside, the actual decimal separator of an <input type="number" /> is read from the browser locale. So you might use a dot 50.5 for an American en-US locale, or a comma for a sv-SE Swedish locale to express the same number by using the string 50,5 instead. So it would be correct to say that a number input is somewhat “locale aware” already, it just doesn’t provide a native way for grouping digits to aid readability.

Libraries that offer solutions

There are several NPM libraries available already with the purpose of fixing this problem. One of the most popular is react-number-format (Github) which I have been using extensively as of now. But it has always been filled with problems, particularly when editing an existing number. The caret would often jump around in unexpected ways. This has lead to both my own frustration and the need to develop several types of “coping strategies” in order allow the input of the desired number.

A simple compromise

I chanced upon the realisation recently that this problem is likely unsolvable in an ideal way, and the best way forward is a compromise.

The basic gist of the compromise is a split state input that alternates between showing a formatted number string “at rest” and an unformatted number when editing. This is essentially done with the focus and blur events changing the type attribute of a normal HTML input between number when editing or text otherwise.

When the HTML input is of type text the value displayed is formatted in the native locale of the browser by using the widely available Intl.NumberFormat class.

Here is the source code of a simple React component that achieves this, with comments on the important bits.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
import React, { useMemo, useState } from 'react';

function InputFormatted (props) {
  const {
    onFocus:onFocusOuter,
    onBlur:onBlurOuter,
    value:valueOuter,
    format,
    ...restOfProps
  } = props;

  const [focused, setFocused] = useState(false);

  const onFocus = ev => {
    setFocused(true);
    onFocusOuter?.(ev);
  };

  const onBlur = ev => {
    setFocused(false);
    // this prevents the string "NaN" from being set as the input value
    // an invalid number, i.e. NaN is always shown as an empty string
    const value = isNaN(ev.target.valueAsNumber) ? '' : ev.target.valueAsNumber;
    ev.target.value = String(value);
    onBlurOuter?.(ev);
  };

  const value = useMemo(() => {
    if (valueOuter === '') return '';
    if (focused) return valueOuter;
    // the formatting takes place here by calling the prop function format
    return format(valueOuter);
  }, [focused, valueOuter, format]);

  // the essential bit
  const type = focused ? 'number' : 'text';

  // shows the numeric keyboard when focusing the input on a mobile phone
  // important for a good UX
  const inputMode = 'numeric';

  return (
    <input
      {...restOfProps}
      onBlur={onBlur}
      onFocus={onFocus}
      type={type}
      inputMode={inputMode}
      value={value}
    />
  );
}

A benefit of this component as compared to other solutions using libraries is that any <input /> can be seamlessly swapped to a <InputFormatted /> without having to rig up any listeners for custom events. It extends the attributes of a normal HTML input in its entirety with the addition of the “format” function which must be provided.

Below is a live demonstration of the <InputFormatted /> component. The locale is read from the browser so the formatted value should correspond to how you would expect to read it in your own locale.

If this doesn't disappear soon, the component did not load.

Here is the source code of the demonstration component.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
// an undefined first argument here means "browser locale"
const formatter = new Intl.NumberFormat(undefined, {
  useGrouping: true,
});

export default function ReactInputNumberFormatting () {
  // the type of value will be an empty string, or a valid number
  const [value, setValue] = useState(5000000);

  const onChange = ev => {
    // any non-valid number is translated into an empty string
    setValue(isNaN(ev.target.valueAsNumber) ? '' : ev.target.valueAsNumber);
  };

  return (
    <table className="table align-middle table-bordered">
      <tbody>
        <tr>
          <th className="text-end"><strong>Stored value: </strong></th>
          <td>{value}</td>
        </tr>
        <tr>
          <th className="text-end"><strong>Formatted value: </strong></th>
          <td>{formatter.format(value)}</td>
        </tr>
        <tr>
          <th className="text-end"><strong>Type of value: </strong></th>
          <td>{typeof value}</td>
        </tr>
        <tr>
          <th className="text-end"><strong>User input: </strong></th>
          <td>
            <InputFormatted
              className="form-control w-auto form-control-sm"
              onChange={onChange}
              value={value}
              format={formatter.format}
            />
          </td>
        </tr>
      </tbody>
    </table>
  );
}

An empty string is much like a null-value for most HTML form-related tags such as <input /> and <select />. You are encouraged to try to empty the demonstration input above to see how this is handled. Opinions on how to handle an invalid or empty value will vary, but I prefer to use native JavaScript numbers as much as I can but without fighting the “empty string as null”-convention when possible.

For stricter typing in the sense that value is guaranteed to always be a string, the onChange event handler should use the ev.target.value property instead of ev.target.valueAsNumber. If it is a requirement that only valid numbers are used (somewhere) then two useState hooks must be used: one containing the user input, the other containing the always valid number.

This could look like the code below, where the number variable will take on the value zero when the user inputs something invalid. Users will always manage to input invalid values somehow in my experience, so strategies to handle this will have to be developed for all user-facing applications.

1
2
3
4
5
6
7
  const [number, setNumber] = useState(5000000)
  const [value, setValue] = useState('5000000');

  const onChange = ev => {
    setValue(ev.target.value);
    setNumber(isNaN(ev.target.valueAsNumber) ? 0 : ev.target.valueAsNumber);
  };

Benefits and drawbacks

One drawback is that … there is no parsing being done. So copying the value from the “Formatted value” cell above and trying to paste it into the “User input” will not work because the grouping characters in the paste data would be illegal to a number input. Using the “Stored value” or the actual “User input” value will work, however.

I suppose the paste-problem could be solved by listening to the paste event and attempting to parse the pasted value to fit the <input type="number" />, but then we are back to parsing again, and it is not something I’d like to implement.

Another drawback, and probably the biggest one, is that editing the actual value removes the grouping of numbers. So it is still hard to see exactly what you are doing when you are editing a very large number, but only while you are doing the actual editing, not when you are doing the passive reading that (usually) both precedes and succeeds it. This is an acceptable trade-off to me.

All things considered, I believe this solution is as close to ideal as possible. The biggest advantage is undoubtedly the simplicity of the implementation. If you can think of any other pros or cons, or perhaps suggestions for improvement, then feel free to email me.

This website

As of the publishing of this article, I have updated all the tools that are built with React to use this new component. Most importantly the Portfolio Balancer. However some tools are built using Vanilla JS and while the component InputFormatted can be easily adapted to that I will have to return to that later this year.