February 13, 2023

. 12 min read

Build React Form With This Best Practice

Learn how to create and handle forms in React with the best practice in mind. This tutorial covers everything from fundamental concepts to advanced techniques.

––– views

Many web applications built with React have sections that display form input to take the user’s data.

This lesson will discuss how to best handle form inputs in React. We will build a side project to explain the different input types, including checkbox, text, select input, radio, range, and text area.

While there are React form libraries like Formik and React Hook Form that hastens form integration in a project, knowing the logic behind forms is beneficial and will even make using a third-party library easier.

The Gif below demonstrates the form project we will build together in this lesson. You can interact with it here, and after that, get started!

controlled-form-input

React Form vs. HTML Form

Form elements in React JSX are handled differently compared to that in HTML. In HTML, form inputs keep their internal state and are maintained by the browser DOM. This DOM is the “source of truth,” and any data passed to the form input will be available inside it (i.e., the DOM).

React however propose a different approach to working with form elements. Form inputs in React can either be controlled or uncontrolled input.

Uncontrolled React Form Input

This type of input behavior is similar to that of the HTML inputs, as the DOM handles the input data.

Consider the following rendered form elements:

const Form = () => {
  return (
    <>
      <h1>React Form Handling</h1>
      <form>
        <label>
          First Name: <input type="text" />
        </label>
      </form>
    </>
  );
};
export default Form;

If we temporarily add a value prop and assign an empty string, for instance, the form input immediately becomes read-only and seize to be altered:

<input type="text" value="" />

This is because the value attribute overrides the value in the DOM. In an uncontrolled implementation, we don’t specify a value attribute for the DOM to handle the element data. If we must specify an initial value, we can use the defaultValue attribute instead.

Getting Data From the React Input

To get form data from an uncontrolled input field, React lets us use a ref to access the input DOM element and pull value from the DOM.

Consider the following code:

import { useRef } from "react";

const UncontrolledForm = () => {
  const ref = useRef();
  const handleSubmit = (e) => {
    e.preventDefault();
    alert(ref.current.value);
  };
  return (
    <>
      <h1>Uncontrolled Form</h1>
      <form onSubmit={handleSubmit}>
        <label>
          First Name: <input type="text" ref={ref} />
        </label>
        <input type="submit" />
      </form>
    </>
  );
};
export default UncontrolledForm;

We started by passing the reference object to the input field to access the field value. Then, we controlled the submit action by adding an event handler on the <form>’s onSubmit attribute.

The form submission will trigger the handleSubmit handler and display the input value. See the demo and the complete code on CodeSandbox.

Later in the series, we'll use uncontrolled implementation to manage unnecessary component re-rendering efficiently.

Controlled React Form Input

With a controlled input, we handle the input data in a React component, not the browser DOM. In a React project, we often use a controlled implementation over its counterpart.

Notes on the React controlled input:

  • A component state is needed to serve as the source of truth instead of DOM.
  • Input elements listen to the component state by taking a checked attribute for checkboxes or a value attribute for other input elements.
  • An event handler is needed to get the input value for every state update.

Let’s see how all of these connect!

React Text Input Field

In a Form component, we will create a state to manage the user’s input. We will then pass the current state as value to the input’s value attribute:

import { useState } from "react"
const Form = () => {
  const [fname, setFname] = useState("")
  return (
    <>
      <h1>Controlled Form</h1>
      <form>
        <label>
          First Name: <input type="text" value={fname} />
        </label>
      </form>
      <h5>First name: {fname}</h5>
    </>
  );
};
export default Form;

By adding the state value to the input's value attribute, the element now listens only to the state. Since the initial state value is an empty string, the input field will also be blank on the initial DOM render. With this, we have made the field a controlled input.

We cannot change the input content, so we need an onChange event handler that listens to a change in the input field. This handler will call the setFname state updater function with the current input value:

import { useState } from "react"
const Form = () => {
  const [fname, setFname] = useState("");
  
  const handleChange = (e) => {
    setFname(e.target.value);
  };
  
  return (
    <>
      <h1>Controlled Form</h1>
      <form>
        <label>
          First Name:{" "}
          <input type="text" value={fname} onChange={handleChange} />
        </label>
{/* ... */}

For every change in the input field, the onChange is triggered and calls the handleChange handler with the latest input value. Once the state is updated, React re-renders the component and ensures the UI reflects the current state value.

controlled-react

With this, we can do many things including instant field validation. That is impossible with uncontrolled input, where we can only get input values from the DOM after form submission.

Multiple Text Input Fields

Let's see the best and easiest way to handle multiple form fields. We will add another text input that collects the user’s last name.

We can set up another state for the last name and assign the state value to the input’s value prop. However, this approach requires defining another handler function to update the input state.

That is okay if we have a few input fields. However, it may be tedious for many input fields. We want to manage all the input states with a single useState() Hook and a single handler function.

To do that, we will change the state value from a string to an object containing all the related state data. We will update the Form component, so we have the following:

import { useState } from "react";
const Form = () => {
  const [state, setState] = useState({
    fname: "",
    lname: ""
  });
  return (
      //    
  );
};
export default Form;

The first and last names will now be available via the state.fname and state.lname respectively.

You can use any convenient state name. You mustn’t use state and setState.

Next, in the JSX, let’s ensure the input’s value attribute reflect the updated state value:

return (
  <>
    <h1>Controlled Form</h1>
    <form>
      <label>
        First Name:{" "}
        <input type="text" value={state.fname} onChange={handleChange} />
      </label>
      <label>
        Last Name:{" "}
        <input type="text" value={state.lname} onChange={handleChange} />
      </label>
    </form>
    <h5>
      Name: {state.fname} {state.lname}
    </h5>
  </>
);

After that, let’s add a name attribute to each input and ensure it matches the name we specified in the state. This lets React know how to update the state for a specific input.

<form>
  <label>
    <input
      name="fname"
      // ...
    />
  </label>
  <label>
    <input
      name="lname"
      // ...
    />
  </label>
</form>

Finally, let’s update the handleChange handler, so we have the following:

const handleChange = (e) => {
  setState({
    ...state,
    [e.target.name]: e.target.value
  });
};

What is happening in the handleChange?

This handler, as mentioned earlier, is called whenever an input field updates, and it will invoke the setState updater function to update the state. The [e.target.name] used in the function lets us dynamically update the state object key corresponding to the active input. For example, if the input field of the first name changes, the fname assigned to the name prop replaces [e.target.name] like so:

setState({
  ...state,
  fname: e.target.value,
})

Earlier in the series, we mentioned that React adopts functional programming concepts. It treats mutable data structures like objects and arrays as immutable data. Meaning we must not modify the original state when it is an object. Instead, we must make a copy using the ES6 spread operator and add the affected property.

At this point, if we test the project, it will work as expected!

Avoiding a common bug

If we ignore the functional programming concept and do not copy the object before updating:

const handleChange = (e) => {
  setState({
    // ...state,
    [e.target.name]: e.target.value
  });
};

We'll introduce a bug! If we try to fill the input fields, the input text overrides one another in the UI.

Another functional programming concept that React adopts is ensuring that the updater function, in this case, setState uses a state variable that passes as an argument of a callback function:

const handleChange = (e) => {
  setState((state) => ({
    ...state,
    [e.target.name]: e.target.value
  }));
};

Now that we know how the control field works in React, adding other form fields should be a piece of cake.

React Textarea Field

A text area is a multi-line text input field. Unlike in HTML, React JSX textarea is a self-closing element similar to the text input.

To add a textarea to our project, we’ll start by adding a state variable for the user’s input. Let’s call it a message:

const [state, setState] = useState({
  fname: "",
  lname: "",
  message: "",
});

Next, we will render a <textarea /> element with the name, value, and onChange attributes in the return statement:

return (
  <>
    <form>
      {/* ... */}
      <label>
        Your Message:
        <textarea
          name="message"
          value={state.message}
          onChange={handleChange}
        />
      </label>
    </form>
    <h5>{/* ... */}</h5>
    <p>Message: {state.message}</p>
  </>
);

Because the textarea also uses a value attribute like the text input, the handleChange handler will also work without modifying it.

Select Input Field

The <select> element defines a dropdown list using the <option> for menu options. Here, we will provide choices for the users to select a car brand from a list. We’ll start by adding a state property for the user’s dropdown selection:

const [state, setState] = useState({
  // ...
  carBrand: "",
});

In the Form component, we’ll add the following car items above the return statement:

const carBrands = ["Mercedes", "BMW", "Maserati", "Infinity", "Audi"];

Next, we’ll loop through the carBrands array and render each item in the <option> element:

const carBrandOptions = carBrands.map((carBrand, key) => (
  <option value={carBrand} key={key}>
    {carBrand}
  </option>
));

Then, we will render the carBrandOptions list in the <select> element:

return (
  <>
    <form>
      {/* ... */}
      <label>
        Car brand:
        <select
          name="carBrand"
          value={state.carBrand}
          onChange={handleChange}
        >
          <option value={""} disabled>
            --Pick a car brand--
          </option>
          {carBrandOptions}
        </select>
      </label>
    </form>
    {/* ... */}
    <h5>Favorite car brand: {state.carBrand}</h5>
  </>
);

Notice how we added <option> element with a value={""} and disabled attributes in the <select> element. This lets us default the selected value to the text contained inside the element.

React Checkbox Input Field

Unlike the other fields discussed above, the input checkbox uses a checked attribute instead of a value attribute and takes a boolean true or false value. Presently, in the handleChange handler, we only made provisions for inputs with value attributes using e.target.value. We will modify the handler to accommodate input with checked attributes.

In the Form component, let’s start by adding a new property to toggle a checkbox:

const [state, setState] = useState({
  // ...
  isChecked: false,
});

This initial value of false will make the checkbox unchecked on the initial DOM render. We will now render the input checkbox in the return statement:

return (
  <>
    <form>
      {/* ... */}
      <label>
        <input
          type="checkbox"
          name="isChecked"
          checked={state.isChecked}
          onChange={handleChange}
        />
        Is Checked?
      </label>
    </form>
    {/* ... */}
    <h5>Is it checked? : {state.isChecked ? "Yes" : "No"}</h5>
  </>
);

Next, we’ll update the handleChange to accommodate the checkbox inputs.

const handleChange = (e) => {
  const value = e.target.type === "checkbox" ? e.target.checked : e.target.value;
  setState((state) => ({
    ...state,
    [e.target.name]: value
  }));
};

In the code, we used a ternary operator (inline if-statement) to check if the target input element is a checkbox or other types, then we update the state accordingly.

React Radio Inputs

Radio buttons are presented in radio groups to allow users to select one button at a time. We will add a gender to our Form component to let users select a gender. To implement this input type, we will combine the logic of the input text and that of the checkbox by using the value and checked attributes.

Let’s start by adding a state for users' gender:

const [state, setState] = useState({
  // ...
  gender: "",
})

Then, in the return statement, we will add radio inputs with name, value, checked, and onChange attributes for both genders:

return (
  <>
    <form>
      {/* ... */}
      <label>
        <input
          type="radio"
          name="gender"
          value="male"
          checked={state.gender === "male"}
          onChange={handleChange}
        />{" "}
        Male
      </label>
      <label>
        <input
          type="radio"
          name="gender"
          value="female"
          checked={state.gender === "female"}
          onChange={handleChange}
        />{" "}
        Female
      </label>
    </form>
    {/* ...  */}
    <h5>Gender : {state.gender}</h5>
  </>
);

For this input type to allow a selection at a time, the name attribute must be the same and equal to the key we specified in the state. The value attribute is assigned a static value that uniquely identifies which radio button is selected. The checked attribute lets us select a button if the condition assigned returns true.

React Range Input Element

We use this input type to filter a list of items based on numeric values. In our project, we will set up a control that displays dynamic prices between 0 and $50.

As expected, we’ll update the state to include a price property like so;

const [state, setState] = useState({
  // ...
  price: 0
});

Then, in the return statement, we will add a range input with a name, value, and onChange attributes.

return (
  <>
    <form>
      {/* ... */}
      <label>
        Price (between 0 and 50):
        <input
          type="range"
          name="price"
          min="0"
          max="50"
          value={state.price}
          onChange={handleChange}
        />
      </label>
    </form>
    {/* ... */}
    <h5>Price : ${state.price}</h5>
  </>
);

The min and max attributes let us set restrictions in the control

Submit React Form

Similar to how we handled submission with uncontrolled form inputs, we’ll control the submit action by adding an event handler on the <form>’s onSubmit attribute.

const Form = () => {
  const [state, setState] = useState({
    // ...
  });
  // ...
  const handleSubmit = (e) => {
    e.preventDefault();
    console.log(state);
  };
  return (
    <>
      <h1>Controlled Form</h1>
      <form onSubmit={handleSubmit}>
        {/* ... */}
        <button>Submit</button>
      </form>
      {/* ... */}
    </>
  );
};
export default Form;

See the complete source code for the form on CodeSandbox. You can interact with the form and see the Console tab for the submission.

And that is it for this lesson!

In the next lesson, we will utilize what we learned on this page and integrate form inputs into our todos primary project.

Next part: Raising and Handling Events in React

continue

share

Discussion