Three ways to build React forms– Part 2

Custom validation with a Controlled Component

in

In Part 1 we talked about building a simple contact form using an uncontrolled component. In an uncontrolled component, validation is left up to the browser. As we discussed, this can lead to an inconsistent experience across browsers.

In this tutorial we’ll modify our basic form by taking control of the form’s state. In doing this we’ll create what React calls a “controlled component.” With a controlled component we’ll have more control over the form, allowing us to create a consistent validation experience, and customize it however we like.

Once again we’re working with Formspree on the backend, but the principles should still apply if you’re using your own form backend.

Building a Controlled Component Form

To create a controlled component, we need to add handlers for each input event and take control of the inputs’ states. Below I’ve taken the original uncontrolled form from Part 1 and reimplemented it as a controlled component. The new changes are highlighted.

 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
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
import React, { useState } from "react";
import axios from "axios";

function MyForm() {
  /* NEW: Input state handling vvvv */
  const [inputs, setInputs] = useState({
    email: "",
    message: ""
  });
  const handleOnChange = event => {
    event.persist();
    setInputs(prev => ({
      ...prev,
      [event.target.id]: event.target.value
    }));
  };
  /* End input state handling ^^^^ */

  // Server state handling
  const [serverState, setServerState] = useState({
    submitting: false,
    status: null
  });
  const handleServerResponse = (ok, msg) => {
    setServerState({
      submitting: false,
      status: { ok, msg }
    });
    if (ok) {
      setInputs({
        email: "",
        message: ""
      });
    }
  };
  const handleOnSubmit = event => {
    event.preventDefault();
    setServerState({ submitting: true });
    axios({
      method: "POST",
      url: "https://formspree.io/f/{form_id}",
      data: inputs
    })
      .then(r => {
        handleServerResponse(true, "Thanks!");
      })
      .catch(r => {
        handleServerResponse(false, r.response.data.error);
      });
  };

  return (
    <div>
      <h1>Contact Us</h1>
      <form onSubmit={handleOnSubmit}>
        <label htmlFor="email">Email:</label>
        <input
          id="email"
          type="email"
          name="email"
          required
          onChange={handleOnChange}
          value={inputs.email}
        />
        <label htmlFor="message">Message:</label>
        <textarea
          id="message"
          name="message"
          onChange={handleOnChange}
          value={inputs.message}
        ></textarea>
        <button type="submit" disabled={serverState.submitting}>
          Submit
        </button>
        {serverState.status && (
          <p className={!serverState.status.ok ? "errorMsg" : ""}>
            {serverState.status.msg}
          </p>
        )}
      </form>
    </div>
  );
};

export default MyForm;

Notes:

  • On line 6, a new state variable inputs has been introduced to capture the state of the form. It’s an object with a key for every input field. We’ve also added a handleOnChange function that we can call to update the inputs state.
  • On line 62 and 63 an input is bound to it’s state through the onChange and value attributes.
  • Everything else is the same as the basic form in Part 1.

At this point we have a controlled component and we haven’t added that much code. However it doesn’t offer any meaningful improvements over our form in Part 1 — at least not yet. To see the value of controlled components, we need to add our own validation.

Adding custom validation

With custom validation we can define our own rules and control the rendering of error states. We’ll discuss the benefits and drawbacks in more depth at the end, but first let’s modify our form.

To add validation we need 4 things:

  1. A way to store field error states
  2. Validation rules for each input
  3. A validate function that will check each input and update the validation state
  4. Some way to display the validation results

Here’s the new validation code that meets the four requirements above. I’ve broken down into 4 steps. Each block of code will be inserted at the top of MyForm.

  // Step 1. create new state to track field errors
  const [fieldErrors, setFieldErrors] = useState({});

  // Step 2. add validation rules for each input field
  const validationRules = {
    email: val => val && /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(val),
    message: val => !!val // similar to "required"
  };

  // Step 3. create a validate function that updates state, and returns
  //    true if all rules pass
  const validate = () => {
    let errors = {};
    let hasErrors = false;
    for (let key of Object.keys(inputs)) {
      errors[key] = !validationRules[key](inputs[key]);
      hasErrors |= errors[key];
    }
    setFieldErrors(prev => ({ ...prev, ...errors }));
    return !hasErrors;
  };

  // Step 4. add a render method to display field errors
  const renderFieldError = field => {
    if (fieldErrors[field]) {
      return <p className="errorMsg">Please enter a valid {field}.</p>;
    }
  };

We’re not done yet. Now we need to call the validate function when the user clicks submit. If the validation fails we shouldn’t send the form submission.

  const handleOnSubmit = event => {
    event.preventDefault();
    if (!validate()) {
      return;
    }
    /* ... rest of handleOnSubmit... */ 

Then, in our main render method, we can call renderFieldError for each input. For example, just after the email input field, we can add this line:

  {renderFieldError("email")}

We also want to disable default validation on the form. That can be accomplished by adding the noValidate attribute:

  <form onSubmit={handleOnSubmit} noValidate>

If the form is submitted successfully, we should clear any field errors in handleServerResponse:

  const handleServerResponse = (ok, msg) => {
    // ... 
    if (ok) {
      setFieldErrors({}); // <-- clear field errors 
      setInputs({
        email: "",
        message: ""
      });
    }
  };

Finally, it would be cool if after the first submit, we perform input validation for each keystroke. That way the user gets fast feedback while correcting field errors. We can do that with a React useEffect hook that’s called each time the inputs change.

  useEffect(() => {
    // Only perform interactive validation after submit
    if (Object.keys(fieldErrors).length > 0) {
      validate();
    }
  }, [inputs]);

Note: we only call validation if there are already fieldErrors. Otherwise the form will display errors even before the user has had a chance to complete the form.

Here’s how the form looks now that we’ve got some basic validation set up:

Contact us form with custom validation

And here’s the full form code with the new validation logic highlighted:

  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
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
import React, { useState, useEffect } from "react";
import axios from "axios";

function MyForm() {
  /* NEW: validation for inputs vvvv */
  const [fieldErrors, setFieldErrors] = useState({});
  const validationRules = {
    email: val => val && /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(val),
    message: val => !!val
  };
  const validate = () => {
    let errors = {};
    let hasErrors = false;
    for (let key of Object.keys(inputs)) {
      errors[key] = !validationRules[key](inputs[key]);
      hasErrors |= errors[key];
    }
    setFieldErrors(prev => ({ ...prev, ...errors }));
    return !hasErrors;
  };
  const renderFieldError = field => {
    if (fieldErrors[field]) {
      return <p className="errorMsg">Please enter a valid {field}.</p>;
    }
  };
  useEffect(() => {
    // Only perform interactive validation after submit
    if (Object.keys(fieldErrors).length > 0) {
      validate();
    }
  }, [inputs]);
  /* End new validation ^^^^ */

  // Input Change Handling
  const [inputs, setInputs] = useState({
    email: "",
    message: ""
  });
  const handleOnChange = event => {
    event.persist();
    setInputs(prev => ({
      ...prev,
      [event.target.id]: event.target.value
    }));
  };

  // Server State Handling
  const [serverState, setServerState] = useState({
    submitting: false,
    status: null
  });
  const handleServerResponse = (ok, msg) => {
    setServerState({
      submitting: false,
      status: { ok, msg }
    });
    if (ok) {
      setFieldErrors({});
      setInputs({
        email: "",
        message: ""
      });
    }
  };
  const handleOnSubmit = event => {
    event.preventDefault();
    if (!validate()) {
      return;
    }
    setServerState({ submitting: true });
    axios({
      method: "POST",
      url: "https://formspree.io/YOUR_FORM_ID",
      data: inputs
    })
      .then(r => {
        handleServerResponse(true, "Thanks!");
      })
      .catch(r => {
        handleServerResponse(false, r.response.data.error);
      });
  };

  return (
    <div>
      <h1>Contact Us</h1>
      <form onSubmit={handleOnSubmit} noValidate>
        <label htmlFor="email">Email:</label>
        <input
          id="email"
          type="email"
          name="email"
          onChange={handleOnChange}
          value={inputs.email}
          className={fieldErrors.email ? "error" : ""}
        />
        {renderFieldError("email")}
        <label htmlFor="message">Message:</label>
        <textarea
          id="message"
          name="message"
          onChange={handleOnChange}
          value={inputs.message}
          className={fieldErrors.message ? "error" : ""}
        ></textarea>
        {renderFieldError("message")}
        <button type="submit" disabled={serverState.submitting}>
          Submit
        </button>
        {serverState.status && (
          <p className={!serverState.status.ok ? "errorMsg" : ""}>
            {serverState.status.msg}
          </p>
        )}
      </form>
    </div>
  );
};

Benefits and Drawbacks

Implementing a custom controlled form has several benefits over the uncontrolled form.

  • We can define our own validation logic. This ensures that the form will consistently produce the data we expect, regardless of the browser.
  • State is managed by the form component. That means we can create a custom validation experience, and know that it will render and behave consistently across browsers.
  • The form is able to take full advantage of React’s event and rendering loop. This lets us respond to each change and perform validation per keystroke, rather than at the end when the user clicks submit.

However there are some pretty major drawbacks as well:

  • We need to add event handling code for every input field. This gets pretty complex quickly.
  • Most event handling code is similar, resulting in a lot of boilerplate code.
  • Validation rules are manual. To achieve the same breadth of validation functionality provided by browsers, we’d need to implement a lot of rules.

For simple forms, rolling our own event handling and validation is manageable. But after 3 or 4 inputs, the form code may become a burden. Instead, we can use a form library that comes with this functionality built-in. In Part 3 we’ll be recreating our basic form with the a popular form library Formik. We’ll see how it helps us maintain the control and consistency of a controlled component form without quite as much boilerplate code.


Got Feedback?