DEV Community

loading...
Cover image for How to handle invalid user inputs in React forms for UX design best practices

How to handle invalid user inputs in React forms for UX design best practices

masakudamatsu profile image Masa Kudamatsu Originally published at Medium ・14 min read

TL;DR

Showing error on blur and hiding it immediately after correction is the best practice in web form design. To implement it with React, write code as in this CodeSandbox demo. Then we can achieve the user experience like this:A user enters an invalid text, blurs by clicking next field, sees the error message which disappears immediately after the user corrects the error

Introduction

Best UX design practices for web forms

Wroblewski (2009), Holst (2016), and Krause (2019) all say that we should display an error on blur (i.e. when the user leaves a field), rather than immediately after the user has entered an invalid character. Holst (2016) reports why, based on their e-commerce checkout usability research:

"Most people don’t like being told they are wrong – especially when they aren’t. Users therefore naturally find it very frustrating and (quite understandably) feel unfairly reprimanded when a site claims they’ve made a mistake before they’ve had a chance to enter a valid input.

"Moreover, during testing, the sudden appearance of an error message disrupted many of the subjects in their typing of a perfectly valid input, as they stopped to read and interpret the error. Even worse, some subjects misinterpreted this message to mean that what they had entered so far contained some error and thus began looking for mistakes that weren’t there."

In addition, Holst (2016) argues that the error should disappear as soon as the user corrects it, for the following reason:

"During testing, the subjects ... would look intensely at the error message as they made corrections (i.e. on a keystroke level), expecting the error to be removed as soon as the field contained the valid input. Not removing error messages live, as the error is corrected, can thus cause grave issues as users are likely to misinterpret their newly corrected (and now valid) input to still contain errors."

How would you as a web developer implement this best practice in UX design for web forms? For vanilla JavaScript, Ferdinandi (2017a) explains how. What about React, without using libraries like Formik?

This article proposes a React implementation of the "Show the error on blur and hide it as soon as the user corrects it" user experience, based on my own experiences of building a form from scratch for my own front-end apps Line-height Picker and Triangulum Color Picker.

Number Input Field as an Example

As an example of web forms, we will build a number input field for which we probably do not want to use <input type="number"> for several reasons including:

  • There's no way to tell the user why they cannot enter non-numerical characters (Lanman (2018))
  • Magic Mouse may unintentionally change the input value (Frost (2019))
  • Removing the tiny arrow buttons is difficult with CSS (tao (2017))
  • It doesn't work as intended with some screen readers (Laakso (2020))

So every web developer should know how to build a number input field from scratch.

However, most of the content below equally applies to other types of text fields like the one for passwords, URLs, e-mail addresses, and so forth.

Step 1 of 8: Text Input React Way

We start with the standard way of making a text field with React:

import { useState } from "react";

export default function NumberInputForm() {
  const [userInput, setUserInput] = useState("");
  const handleChange = (event) => {
    setUserInput(event.target.value);
  };
  return (
    <form>
      <label htmlFor="number-input-field">Enter a number: </label>
      <input
        type="text"
        id="number-input-field"
        onChange={handleChange}
        value={userInput}
      />
    </form>
  );
}
Enter fullscreen mode Exit fullscreen mode

For why we should set the <input> element's onChange and value props this way, see React (2021).

Even in the case of a single text field, we should wrap the and elements with the element, to allow screen readers to activate the form filling mode (VanToll (2013)).

Optional: Disable the implicit submission

When there is only one <input> element within the <form> element, we need to disable what's known as implicit submission: hitting the Enter key "submits" the input value and resets it by reloading the page (see VanToll (2013) for detail).

We do not want the user to lose the value they have entered if they accidentally hit the Enter key. Some users (like me) may have formed a habit of hitting the Enter key unconsciously once they feel they have entered everything.

So we add the submit event handler to the <form> element tag:

<form onSubmit={handleSubmit}>
Enter fullscreen mode Exit fullscreen mode

and disable its default behavior:

  const handleSubmit = event => {
    event.preventDefault();
  };
Enter fullscreen mode Exit fullscreen mode

We don't have to do this when there are multiple <input> elements inside the <form> element. Below we omit this code to make exposition simple.

Step 2 of 8: Set the keyboard to display for mobile device users

We can disable non-numerical character entry for mobile devices by adding inputMode="decimal" to the <input> element:

      <input
        type="text"
        id="number-input-field"
        inputMode="decimal"      // ADDED
        onChange={handleChange}
        value={userInput}
      />
Enter fullscreen mode Exit fullscreen mode

We use inputMode='decimal' instead of inputMode='numeric' so that not only Android but also iOS show a number pad. See Holachek (2020) for more detail.

For other types of text fields (phone numbers, email addresses, URLs, search words), use as the inputmode attribute value "tel", "email", "url", "search", respectively. See Olif (2019) for more detail.

Step 3 of 8: Alert the user on blur

What we want to achieve in step 3 is to alert the user after they blur the <input> element rather than immediately after they enter a non-numeric character. As described at the beginning of this article, that's what UI designers recommend as the best practice.

Step 3.1: Set the pattern attribute value to be a regular expression for expected characters

To alert the user for non-numerical input values, we first need to tell whether the user has entered non-numerical characters. For this purpose, we set the pattern attribute for the <input> element:

      <input
        type="text"
        id="number-input-field"
        inputMode="decimal"
        onChange={handleChange}
        pattern="[-]?[0-9]*[.,]?[0-9]+"     // ADDED
        value={userInput}
      />
Enter fullscreen mode Exit fullscreen mode

The pattern attribute takes a regular expression as its value, indicating what characters are accepted. And one way for writing a regular expression for any numbers is as follows (Ferdinandi (2017b)):

[-]?[0-9]*[.,]?[0-9]+
Enter fullscreen mode Exit fullscreen mode

Let me decipher this regular expression step by step.

First, [-]? means the minus sign can be added at the beginning, with ? indicating either none or one of the preceding character (enclosed in brackets) is allowed. If we don't want the user to enter a negative value, we should remove this.

Next, [0-9]* means any integer (no matter how many digits it has) can be added, with * indicating zero or any number of the preceding character is allowed.

So far we've allowed any integer, both positive and negative. If we also want to allow decimals as well, then, first of all, we need to allow a decimal point with [.,]? where we allow both Anglo-Saxon (dot) and continental European (comma) ways of writing a decimal point. Then, [.,]?[0-9]+ means the decimal point should be followed by at least one numerical character, where + indicates at least one preceding character is required.

Notice that we allow zero occurrence of numeric characters before the decimal point with [0-9]* because some people enter a decimal smaller than 1 in the form of, say, .39.

Note also that the expression [0-9]+ at the end also means at least one numeric character is required when there is no decimal point, that is, any integer.

Understanding regular expressions is critical for web developers to flexibly set the requirements of user inputs. I recommend RegexOne, an interactive tutorial thanks to which I've managed to overcome my difficulty of understanding regular expressions.

Step 3.2: Add a blur event handler to turn the error on for invalid values

Then we add a blur event handler:

export default function NumberInputForm() {
  ...
  // ADDED FROM HERE
  const handleBlur = (event) => {
    if (event.target.validity.patternMismatch) {
    }
  };
  // ADDED UNTIL HERE  
  ...  
  return (
    ...
    <input
      type="text"
      id="number-input-field"
      inputMode="decimal"
      onBlur={handleBlur}              // ADDED
      onChange={handleChange}
      pattern="[-]?[0-9]*[.,]?[0-9]+"
      value={userInput}
      />
  );
}

Enter fullscreen mode Exit fullscreen mode

where event.target.validity.patternMismatch indicates whether the user has entered a value that does not satisfy the pattern attribute value. We create the error state and turn it on within its code block:

export default function NumberInputForm() {
  ...
  const [error, setError] = useState(false);    // ADDED

  const handleBlur = (event) => {
    if (event.target.validity.patternMismatch) {
      setError(true);                            // ADDED
    }
  };
  ...
}
Enter fullscreen mode Exit fullscreen mode

Step 3.3: Style the error state

There are several ways to style with CSS in React. For the sake of simple exposition, we use inline styling. (I personally prefer using styled-components, though.)

export default function NumberInputForm() {
  ...
  // ADDED FROM HERE
  function style(error) {
    if (error) {
      return {
        backgroundColor: "rgba(255, 0, 0, 0.5)" 
        // Or any other style you prefer
      };
    }
  }
  // ADDED UNTIL HERE

  return (
    ...
      <input
        type="text"
        id="number-input-field"
        inputMode="decimal"
        onBlur={handleBlur}
        onChange={handleChange}
        pattern="[-]?[0-9]*[.,]?[0-9]+"
        style={style(error)}               // ADDED
        value={userInput}
      />
    ...
  );
}

Enter fullscreen mode Exit fullscreen mode

Step 3.4: Show the error message

The best UI design practice is to tell the user how to correct an invalid value in the text field. To show an error message upon error, we code as follows:

export default function NumberInputForm() {
  ...
  return (
    <form>
      <label htmlFor="number-input-field">Enter a number: </label>
      <input
        type="text"
        id="number-input-field"
        inputMode="decimal"
        onBlur={handleBlur}
        onChange={handleChange}
        pattern="[-]?[0-9]*[.,]?[0-9]+"
        style={style(error)}
        value={userInput}
      />
      {/* ADDED FROM HERE */}
      {error && (
        <p role="alert" style={{ color: "rgb(255, 0, 0)" }}>
          Please make sure you've entered a <em>number</em>
        </p>
      )}
      {/* ADDED UNTIL HERE */}
    </form>
  );
}

Enter fullscreen mode Exit fullscreen mode

We use the short circuit evaluation (&&) so that the error message is injected only when error is true. (See Morelli (2017) for a good introduction to the short circuit evaluation.)

If the errorvariable is true, we render a <p> element with the role="alert" attritube value for accessibility. When an element with this attribute value is programatically inserted into the HTML document, screen readers will read it out (see MDN Contributors (2021)).

And we add the inline styling of style={{ color: "rgb(255, 0, 0)"}}. This color should be the same hue as the one used to indicate the error state so the user can immediately tell that it's related to the reddened text field. That's a common graphic design technique.

Step 4 of 8: Forcibly focus the invalid input element on blur

It's best to let the user immediately correct an invalid value in the text field, rather than asking them to click the text field to start correction.

To do so, we need the useRef hook of React. Let me also show the entire code we've built up so far:

import { useRef, useState } from "react"; // REVISED

export default function NumberInputForm() {
  const [userInput, setUserInput] = useState("");
  const [error, setError] = useState(false);

  function style(error) {
    if (error) {
      return { backgroundColor: "rgba(255, 0, 0, 0.5)" };
    }
  }

  const ref = useRef();    // ADDED

  const handleBlur = (event) => {
    if (event.target.validity.patternMismatch) {
      ref.current.focus(); // ADDED
      setError(true);
    }
  };

  const handleChange = (event) => {
    setUserInput(event.target.value);
  };

  return (
    <form>
      <label htmlFor="number-input-field">Enter a number: </label>
      <input
        type="text"
        id="number-input-field"
        inputMode="decimal"
        onBlur={handleBlur}
        onChange={handleChange}
        pattern="[-]?[0-9]*[.,]?[0-9]+"
        ref={ref}                           // ADDED
        style={style(error)}
        value={userInput}
      />
      {error && (
        <p role="alert" style={{ color: "rgb(255, 0, 0)" }}>
          Please make sure you've entered a <em>number</em>
        </p>
      )}
    </form>
  );
}

Enter fullscreen mode Exit fullscreen mode

Programatically focusing a particular element is one example where we should use the useRef hook of React. See React (2020).

Step 5 of 8: Remove alert as soon as the user corrects the invalid value

As discussed at the beginning of this article, when the user corrects an invalid value, we should tell them immediately that they have done the right thing, rather than telling them when they blur the element.

To do so, we edit the handleChange function:

  const handleChange = (event) => {
    // ADDED FROM HERE
    const newValueIsValid = !event.target.validity.patternMismatch;
    if (error) {
      if (newValueIsValid) {
        setError(false);
      }
    }
    // ADDED UNTIL HERE
    setUserInput(event.target.value);
  };
Enter fullscreen mode Exit fullscreen mode

The newValueIsValid indicates whether a new value the user has just entered is valid or not. If the previous value that the user has entered is invalid (i.e. the error state is true), then we turn of the error as long as the new value is valid. To avoid re-rendering the UI unnecessarily, we want to update the error state only when the error is true.


The remaining three steps below are based on my own preference. But I believe these will contribute to great user experiences on the web form.

Step 6 of 8: Allow the user to blur the text field once they know there is an error

With the code so far, there is one problem: when there is an error, the user cannot blur the <input> element due to the following bit of code:

const handleBlur = (event) => {
    if (event.target.validity.patternMismatch) {
      ref.current.focus();
      setError(true);
    }
  };
Enter fullscreen mode Exit fullscreen mode

But maybe the user wants to do something else on the same webpage, before correcting the invalid value. For the first time they blur, we force their cursor to stay in the text field so they can immediately start correcting the invalid value. For the second time they blur, however, we should allow their cursor to exit from the text field.

To do so, we modify the handleBlur function as follows:

  const handleBlur = (event) => {
    if (!error) {   // ADDED
      if (event.target.validity.patternMismatch) {
        ref.current.focus();
        setError(true);
      }
    } // ADDED
  };
Enter fullscreen mode Exit fullscreen mode

We run the code for focusing the <input> element only when the error is off. When the error turns on after the first blurring, then this code block won't run with the second time the user blurs.

Step 7 of 8: Hide the error message once the user blurs for the second time

However, as the error state persists, the user will see the error message after blurring for the second time. This can be annoying if the error message hides other parts of UI where the user wants to interact with. We want to hide the error message in this case.

To do so, we need to manage whether or not to show the error message separately from the error state:

const [error, setError] = useState(false);
const [showErrorText, setShowErrorText] = useState(false); // ADDED
Enter fullscreen mode Exit fullscreen mode

Then, before adding new code for hiding the error message, refactor the rest of the code to achieve the same results so far. For the handleBlur function to turn on the error:

const handleBlur = (event) => {
    if (!error) {
      if (event.target.validity.patternMismatch) {
        ref.current.focus();
        setError(true);
        setShowErrorText(true);  // ADDED
      }
    }
  };
Enter fullscreen mode Exit fullscreen mode

For the handleChange function to turn off the error:

const handleChange = (event) => {
    const newValueIsValid = !event.target.validity.patternMismatch;
    if (error) {
      if (newValueIsValid) {
        setError(false);
        setShowErrorText(false);  // ADDED
      }
    }
    setUserInput(event.target.value);
  };
Enter fullscreen mode Exit fullscreen mode

And for the error message to be added to the DOM:

      {showErrorText && (            // REVISED
        <p role="alert" style={{ color: "rgb(255, 0, 0)" }}>
          Please make sure you've entered a <em>number</em>
        </p>
      )}


Enter fullscreen mode Exit fullscreen mode

Now it's time to hide the error message after blurring for the second time:

const handleBlur = (event) => {
    if (!error) {
      if (event.target.validity.patternMismatch) {
        ref.current.focus();
        setError(true);
        setShowErrorText(true);  
      }
    }
    if (error) {               // ADDED
      setShowErrorText(false); // ADDED
    }                          // ADDED
  };
Enter fullscreen mode Exit fullscreen mode

When the user blurs for the second time, the error state is already true. So only in that case, turn the showErrorText state off to hide the error message.

Step 8 of 8: Show the error message again when the user is going to correct the invalid value

When the user finally wants to correct the invalid value, we should show the error message again to remind them of what values need to be entered. To do so, we add the focus event handler:

  const handleFocus = () => {
    if (error) {
      setShowErrorText(true);
    }
  };
Enter fullscreen mode Exit fullscreen mode

The handleFocus function turns the showErrorText state on as long as the error state is on.

Then assign this event handler to the <input> element:

     <input
        type="text"
        id="number-input-field"
        inputMode="decimal"
        onBlur={handleBlur}
        onChange={handleChange}
        onFocus={handleFocus}           // ADDED
        pattern="[-]?[0-9]*[.,]?[0-9]+"
        ref={ref}
        style={style(error)}
        value={userInput}
      />
Enter fullscreen mode Exit fullscreen mode

We use the focus event handler, rather than a click event handler, because the user may use the tab key to focus the <input> element. We should show the error message in this case as well.

Summary

Through the above eight steps, we have built the following component:

import { useRef, useState } from "react";

export default function NumberInputForm() {
  const [userInput, setUserInput] = useState("");
  const [error, setError] = useState(false);
  const [showErrorText, setShowErrorText] = useState(false); // ADDED

  function style(error) {
    if (error) {
      return { backgroundColor: "rgba(255, 0, 0, 0.5)" };
    }
  }

  const ref = useRef();

  const handleBlur = (event) => {
    if (!error) {
      if (event.target.validity.patternMismatch) {
        ref.current.focus();
        setError(true);
        setShowErrorText(true);
      }
    }
    if (error) {
      setShowErrorText(false);
    }
  };

  const handleChange = (event) => {
    const newValueIsValid = !event.target.validity.patternMismatch;
    if (error) {
      if (newValueIsValid) {
        setError(false);
        setShowErrorText(false);
      }
    }
    setUserInput(event.target.value);
  };

  const handleFocus = () => {
    if (error) {
      setShowErrorText(true);
    }
  };

  return (
    <form>
      <label htmlFor="number-input-field">Enter a number: </label>
      <input
        type="text"
        id="number-input-field"
        inputMode="decimal"
        onBlur={handleBlur}
        onChange={handleChange}
        onFocus={handleFocus}
        pattern="[-]?[0-9]*[.,]?[0-9]+"
        ref={ref}
        style={style(error)}
        value={userInput}
      />
      {showErrorText && (
        <p role="alert" style={{ color: "rgb(255, 0, 0)" }}>
          Please make sure you've entered a <em>number</em>
        </p>
      )}
    </form>
  );
}

Enter fullscreen mode Exit fullscreen mode

This component provides the following user experiences with the number input field:

  1. When the user enters a non-numerical character, nothing happens immediately after.
  2. But when the user blurs the input field, three things happen: (1) the <input> element's background turns into semi-transparent red ( rgba(255, 0, 0, 0.5) ), (2) an error message "Please make sure you've entered a number" shows up in red (and the screen reader will read it out), (3) the <input> element gets focused so the user can immediately start correcting the invalid value.
  3. If the user clicks/taps somewhere else to interact with other parts of the webpage before correcting the invalid value, the error message disappears while the semi-transparent red background stays for the <input> element.
  4. When the user clicks/taps the <input> element to start correcting the invalid value, then the error message reappears.
  5. As soon as the user finishes correcting the invalid value, the error message disappears and the input field gets back to the default style so the user can quickly tell whether they have entered a valid value or not.

If you need an example of applying the above component into a non-numerical text field, see the source code of a Hex color code field in my front-end app Triangulum Color Picker.


Hope this article will help reduce the number of web forms that irritate users from this world. :-)

References

Ferdinandi, Chris (2017a) "Form Validation Part 2: The Constraint Validation API (JavaScript)", CSS-Tricks, Jun. 27, 2017.

Ferdinandi, Chris (2017b) "Form Validation Part 1: Constraint Validation in HTML", CSS-Tricks, June 26, 2017.

Frost, Brad (2019) "You probably don’t need input type=“number”", bradfrost.com, Mar. 18, 2019.

Holacheck, (2020) "Better Form Inputs for Better Mobile User Experiences", CSS-Tricks, Apr. 17, 2020.

Holst, Christian (2016) "Usability Testing of Inline Form Validation: 40% Don’t Have It, 20% Get It Wrong", Baymard Institute, Sep. 27, 2016.

Krause, Rachel (2019) "How to Report Errors in Forms: 10 Design Guidelines", Nielsen Norman Group, Feb. 3, 2019.

Laakso, Hanna (2020) "Why the GOV.UK Design System team changed the input type for numbers", Gov.uk, Feb. 24, 2020.

Lanman, Joe (2018) "Reconsider the behaviour for type="number" - restricting input", GitHub Issues, Apr. 11, 2018

MDN Contributors (2021) "Using the alert role", MDN Web Docs, Feb. 24, 2021.

Morelli, Brandon (2017) "JavaScript — Short Circuit Conditionals", codeburst.io, Nov 27, 2017.

Olif, Christian (2019) "Everything You Ever Wanted to Know About inputmode", CSS-Tricks, May 17, 2019.

React (2020) "Refs and the DOM", React Docs, Sep 21, 2020.

React (2021) "Forms", React Docs, Jan 13, 2021.

tao (2017) "An answer to 'Customizing Increment Arrows on Input of Type Number Using CSS'", Stack Overflow, Jul. 30, 2017.

VanToll, TJ (2013) "The Enter Key should Submit Forms, Stop Suppressing it", tjvantoll.com, Jan 1, 2013.

Wroblewski, Luke (2009) "Inline Validation in Web Forms", A List Apart, Sep 1, 2009.

Discussion (0)

pic
Editor guide