Intuitive React Integer Input

Input fields are some of the most fundamental aspects of any application, and front-end validation is expected these days. Unfortunately, doing even simple validation is often more complicated than many clients expect.

Requests for name or email fields can become quickly bogged down by edge cases. For example, the regex for an email that is almost always correct1 is:

(?:[a-z0-9!#$%&'*+/=?^_`{|}~-]+(?:\.[a-z0-9!#$%&'*+/=?^_`{|}~-]+)*|"(?:[\x01-\x08\x0b\x0c\x0e-\x1f\x21\x23-\x5b\x5d-\x7f]|\\[\x01-\x09\x0b\x0c\x0e-\x7f])*")@(?:(?:[a-z0-9](?:[a-z0-9-]*[a-z0-9])?\.)+[a-z0-9](?:[a-z0-9-]*[a-z0-9])?|\[(?:(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.){3}(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?|[a-z0-9-]*[a-z0-9]:(?:[\x01-\x08\x0b\x0c\x0e-\x1f\x21-\x5a\x53-\x7f]|\\[\x01-\x09\x0b\x0c\x0e-\x7f])+)\])

It’s clearly very complicated to validate an input, but simply knowing the edge cases is not enough; there’s still the question of what to do about them.

For our purposes, let’s look at a simple, common input: an integer.

When a client asks for a number input between two values, they often mean an integer. <input type="number"/> may be sufficient, but it allows a lot of inputs like 3.14 or 6.022E23 or -.E2-e4.1. The client is drawing upon their experiences with number inputs to inform how they think it should behave, but they probably haven’t been testing edge cases.

Simple Integer Input

For an integer input without bounds, I propose the following user experience.

  1. Only - and the numbers 0 through 9 can be entered.
  2. - can only appear once, as the first character.
  3. An empty input or an input only containing - is considered invalid.
  4. Invalid inputs:
    • Must be highlighted in some way to indicate the value is not being used.
    • Will change back to the last valid value when focus is lost.
  5. Copied values cannot be pasted unless they conform to the prior limits.

There are a few strange inputs that can be entered despite these limits (e.g. -000), but the intent is understandable.

My simple React controlled component for this is:


// IntegerInput.jsx
import React, {useState} from 'react';

const isValid = (value) => value !== '' && value !== '-';

export const IntegerInput = ({ value, onChange }) => {
  const regexp = new RegExp(`^-?[0-9]*$`);
  const [internalValue, setInternalValue] = useState(value);
  const [valid, setValid] = useState(isValid(value));
  return (
    <input type="text"
           className={ valid ? '' : 'invalid' }
           value={ internalValue }
           onChange={ (event) => {
             const newValue = event.target.value;
             if (regexp.test(newValue)) {
               setInternalValue(newValue);
               let newValid = isValid(newValue);
               setValid(newValid);
               if (newValid) {
                 onChange(newValue);
               }
             }
           } }
           onBlur={ () => {
             setInternalValue(value);
             setValid(true);
           } }
    />
  );
};

This component behaves intuitively, allowing the user to delete or copy and paste input while only calling onChange when the value is valid. The key recognition is that two distinct validations are actually happening:

  1. What can be entered?
  2. Is it valid?

An empty input can be entered. A single - can be entered. Letters can not be entered. Multiple - can not be entered. regexp.test prevents values from being entered in the first place.

Once a value is considered “enterable,” the input element reflects the entered value (via intervalValue), but isValid now determines whether the value is valid. If it is, onChange is called and the controlling component is informed of the new value.

onBlur handles resetting invalid input to the last valid value.

While the CSS for .invalid could be anything, I like:


input.invalid:focus {
  outline-color: darkred;
}

Integer Input with Min/Max Bounds

Optionally bounding the input to minimum and/or maximum values adds a couple more details to our expected user experience.

  • If a min or max is set, input outside their range is considered invalid.
  • Invalid inputs:
    • That are less than the min will change to the min when focus is lost.
    • That are greater than the max will change to the max when focus is lost.

The expanded rules handling invalid input are not only intuitive to the user, but they also make the boundaries discoverable.

My improved component now looks like this:


// IntegerInput.jsx
import React, {useState} from 'react';

const isValid = (value, min, max) =>
  value !== '' &&
  value !== '-' &&
  (min === undefined || value >= min) &&
  (max === undefined || value <= max);

export const IntegerInput = ({ value, min, max, onChange }) => {
  const regexp = new RegExp(`^-?[0-9]*$`);
  const [internalValue, setInternalValue] = useState(value);
  const [valid, setValid] = useState(isValid(value, min, max));
  return (
    <input type="text"
           className={ valid ? '' : 'invalid' }
           value={ internalValue }
           onChange={ (event) => {
             const newValue = event.target.value;
             if (regexp.test(newValue)) {
               setInternalValue(newValue);
               let newValid = isValid(newValue, min, max);
               setValid(newValid);
               if (newValid) {
                 onChange(newValue);
               }
             }
           } }
           onBlur={ () => {
             if (internalValue < min) {
               setInternalValue(min);
             } else if (internalValue > max) {
               setInternalValue(max);
             } else {
               setInternalValue(value);
             }
             setValid(true);
           } }
    />
  );
};

The real magic is that the extended user experience is implemented exactly where expected: in the isValid helper function and the onBlur event handler. This easy refactor is only possible due to the prior clarification between enterable and valid input.

Usage

As a controlled component, the owning component needs to ensure that onChange updates value in some way, whether through the component state or through Redux.


const App = () => {
  const [value, setValue] = useState(0);
  return (
    <div className="App">
      <IntegerInput value={ value } min={-23} max={67} onChange={ (value) => setValue(value) }/>
    </div>
  );
};

Hopefully this example of intuitive input validation helps you the next time someone asks for a “simple” form field.

1Example from http://emailregex.com/