react-validacion-formularios

Form Validation in React

  • 6 min

In the previous article, we learned how to control a single input. But in the real world, forms usually don’t have just one field.

A typical registration form has name, email, password, password confirmation, address, acceptance of terms… and very importantly, we need validation rules.

For example,

  • The email must have an email format.
  • The password must be more than 6 characters.
  • The passwords must match.
  • You cannot submit the form if there are errors.

If we try to do this by creating a useState variable for each field and another useState variable for each error, your component will basically become a nightmare.

Today we are going to see how to tackle this without going crazy in the attempt.

Single State Strategy

Instead of having name, setName, email, setEmail… we are going to group the entire form into a single state object. We already hinted at this in the previous article, but now it’s mandatory.

And not only that, now we need a second state for errors.

const [formData, setFormData] = useState({
  username: '',
  email: '',
  password: ''
});

const [errors, setErrors] = useState({});
Copied!

The errors object will have the same key structure as formData, but its values will be error messages (e.g., { email: "Invalid email" }).

If a key doesn’t exist or is empty, we assume that field is valid.

The validate Function

We are going to separate the validation logic from the rendering logic. We’ll create a function that receives the data and returns the errors found.

const validate = (values) => {
  const errors = {};
  const regexEmail = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;

  if (!values.username) {
    errors.username = "Username is required";
  }

  if (!values.email) {
    errors.email = "Email is required";
  } else if (!regexEmail.test(values.email)) {
    errors.email = "Email format is not valid";
  }

  if (!values.password) {
    errors.password = "Password is required";
  } else if (values.password.length < 6) {
    errors.password = "Password must be at least 6 characters";
  }

  return errors;
};
Copied!

This function is pure (it doesn’t modify state, it only calculates and returns). This makes it very easy to test and reuse.

Handling Submission (onSubmit)

This is where we orchestrate everything. When the user tries to submit:

We prevent the reload (preventDefault).

We execute the validation.

If there are errors ➡ We update the errors state and stop the submission.

If there are no errors ➡ We send the data to the API.

const handleSubmit = (e) => {
  e.preventDefault();
  
  // 1. Validate the current data
  const validationErrors = validate(formData);
  
  // 2. Update the visual error state
  setErrors(validationErrors);

  // 3. Check if the error object is empty
  if (Object.keys(validationErrors).length === 0) {
    console.log("Form valid. Sending data...", formData);
    // Here would go the API call
    // sendData(formData);
  } else {
    console.log("Form invalid, correct the errors");
  }
};
Copied!

Visual Feedback (UX)

It’s useless to validate internally if we don’t tell the user what they are doing wrong, through some messages.

In our JSX, we are going to conditionally render an error message below each input only if there is an error associated with that field.

<div>
  <label>Email</label>
  <input 
    type="email" 
    name="email" 
    value={formData.email} 
    onChange={handleChange}
    // Add a conditional CSS class to make the border red
    className={errors.email ? 'input-error' : ''}
  />
  
  {/* Conditional rendering of the message */}
  {errors.email && <span className="error-message">{errors.email}</span>}
</div>
Copied!

The “Immediate Shouting” Problem

If we implement real-time validation (in onChange), we have a User Experience (UX) problem.

If I validate that the email must have an at sign @, as soon as the user types the first letter “L”, a huge red error will pop up: “INVALID EMAIL”. Of course it’s invalid, I haven’t finished typing! That’s very aggressive and annoying.

To solve this, we introduce a third state: Touched.

const [touched, setTouched] = useState({});

const handleBlur = (e) => {
  // When the user leaves the input (loses focus)
  setTouched({
    ...touched,
    [e.target.name]: true
  });
  
  // Optional: Validate here too
};
Copied!

Now, we only show the error if the field has an error and has been touched (or if we have tried to submit).

{errors.email && touched.email && <span className="error">{errors.email}</span>}
Copied!

We will use the onBlur event on the inputs to mark the field as “touched”.

Note on Libraries (React Hook Form)

What we just did is the “manual” way. It’s interesting to see it to understand how things work.

However, for very large forms, managing all this state manually affects performance (every letter re-renders the entire form).

In real life, the norm is to use specialized libraries. The undisputed queen today is React Hook Form.

This library optimizes performance by avoiding unnecessary re-renders and offers us a very clean API for validation. But I don’t recommend jumping to it until you master the manual management we just saw.