Telerik blogs
ReactT2 Dark_1200x303

Today we’ll cover how to create and validate a login form using React Hooks.

Forms are one of the most common features found in web applications. They are often used to collect information from a website’s users and visitors. A good form should be user friendly and provide meaningful feedback if a user does not provide the required information in a correct format (form validation). This article will demonstrate how to use Hooks to implement a React login form with validation that will run whenever a user leaves (blurs) a field or tries to submit the form.

If you have never used React Hooks before, then you should check out this React Hooks guide. It explains in detail what Hooks are, their benefits and how to use the most common ones, such as useState and useEffect. I recommend you get familiar with React Hooks sooner than later, as they are now the standard way of creating React applications. In the past, class components were the primary method of authoring React components, but they were replaced by Hooks, as they are much better for writing reusable and maintainable stateful logic.

Now, let’s start by setting up a React project.

You can find the full code example for this article in this GitHub repo and an interactive StackBlitz code example below.

Project Setup

We are going to use Vite to quickly scaffold a new React project. If you haven’t heard about Vite before, check out my article about it for an introduction—What Is Vite: The Guide to Modern and Super-Fast Project Tooling.

Run the command below in your terminal to create a new React project.

$ npm init vite react-hooks-form -- --template react

After the project creation is complete, run the following commands to get into the project directory and install all dependencies.

$ cd react-hooks-form && npm install

Finally, you can start the development server by running the npm run dev command.

How To Build a React Login Form With Hooks

We are going to build a login form comprising three fields—email, password and confirm password. You can see what we are going to build in the image below.

React login form with hooks

First, we need to update the App.jsx and App.css files, as these were created with some pre-existing JSX and styles that we don’t need.

App.jsx

import "./App.css";
import LoginForm from "./components/loginForm/LoginForm.jsx";

function App() {
  return (
    <div className="App">
      <h1>React form with hooks</h1>
      <div>
        <LoginForm />
      </div>
    </div>
  );
}

export default App;

The App component will render a headline and the LoginForm component that we will create in a moment. Before we create it though, let’s update the styles for the App component.

App.css

.App {
  max-width: 40rem;
  margin: 4rem auto;
  padding: 0 2rem;
}

Next, we can create the LoginForm component.

src/components/loginForm/LoginForm.jsx

import { useState } from "react";
import styles from "./LoginForm.module.css";

const LoginForm = props => {
  const [form, setForm] = useState({
    email: "",
    password: "",
    confirmPassword: "",
  });

  const onUpdateField = e => {
    const nextFormState = {
      ...form,
      [e.target.name]: e.target.value,
    };
    setForm(nextFormState);
  };

  const onSubmitForm = e => {
    e.preventDefault();
    alert(JSON.stringify(form, null, 2));
  };

  return (
    <form className={styles.form} onSubmit={onSubmitForm}>
      <div className={styles.formGroup}>
        <label className={styles.formLabel}>Email</label>
        <input
          className={styles.formField}
          type="text"
          aria-label="Email field"
          name="email"
          value={form.email}
          onChange={onUpdateField}
        />
      </div>
      <div className={styles.formGroup}>
        <label className={styles.formLabel}>Password</label>
        <input
          className={styles.formField}
          type="password"
          aria-label="Password field"
          name="password"
          value={form.password}
          onChange={onUpdateField}
        />
      </div>
      <div className={styles.formGroup}>
        <label className={styles.formLabel}>Confirm Password</label>
        <input
          className={styles.formField}
          type="password"
          aria-label="Confirm password field"
          name="confirmPassword"
          value={form.confirmPassword}
          onChange={onUpdateField}
        />
      </div>
      <div className={styles.formActions}>
        <button className={styles.formSubmitBtn} type="submit">
          Login
        </button>
      </div>
    </form>
  );
};

export default LoginForm;

The login form utilizes the useState hook to store the state for the form. The form state is an object with email, password and confirmPassword fields.

const [form, setForm] = useState({
  email: "",
  password: "",
  confirmPassword: "",
});

After the form state is defined, we have the onUpdateField function, which is passed to each input field as an onChange handler. Even though we have three form fields, we don’t need separate handlers for them. We can use just one function by utilizing field’s name attribute as a form key.

const onUpdateField = e => {
  const nextFormState = {
    ...form,
    [e.target.name]: e.target.value,
  };
  setForm(nextFormState);
};

Further, the onSubmitForm method will be executed when the form is submitted. At the moment, it just prevents the default form submit behavior and then shows an alert with the form’s values.

const onSubmitForm = e => {
  e.preventDefault();
  alert(JSON.stringify(form, null, 2));
};

Finally, the LoginForm component renders a form that comprises three fields—email, password and confirmPassword. Next, let’s create styles for the LoginForm component.

src/components/loginForm/LoginForm.module.css

.form {
  max-width: 30rem;
}

.formGroup {
  display: flex;
  flex-direction: column;
  margin-bottom: 1rem;
}

.formLabel {
  margin-bottom: 0.25rem;
}

.formField {
  padding: 0.5rem;
}

.formFieldError {
  border: 1px solid #e11d48;
}

.formFieldErrorMessage {
  color: #e11d48;
}

.formActions {
  display: flex;
  justify-content: flex-end;
}

.formSubmitBtn {
  padding: 0.5rem 0.7rem;
  min-width: 5rem;
  background-color: #9333ea;
  color: #f3e8ff;
  text-transform: uppercase;
  font-weight: 500;
}

The GIF below shows how the form should look now.

Working login form with React hooks

So, we have a working login form. A user can fill in all the fields and submit the form. However, there is a very import thing that is missing—validation. A user can just click on the login button and the submit method will proceed without checking if the form fields were filled in correctly. Let’s add form validation to prevent a user from submitting an empty form.

Tip: Check out the “Up and Running with React Form Validation” for an in-depth piece on form validation in React.

Login Form Validation on Field Blur With a Custom React Hook

The first question we need to ask ourselves is: When do we want to validate the form? Should all the errors be shown when a user tries to submit the form or if a user focuses and leaves a field? Personally, I prefer to use the latter approach, as users have an opportunity to fill in information for a field and, if they leave it without providing correct information, they will immediately get feedback about it.

It works especially well for larger forms. If a form consists of many fields and a user submits the form, they might need to scroll back to the fields they missed or did not fill in correctly. That’s why I think in most cases it’s better to provide error feedback immediately after a user interacted with a field.

If you would like to know more about how to design and create great forms from UI and UX perspective, you should check out these React Form Design Guidelines.

First, we need to install the clsx helper that can be used to compose classes in a nice way. Run the command below in the terminal.

$ npm install clsx

After the installation is complete, we need to create validators for our fields. These are the validation rules we want to enforce:

  • The email must be provided and have correct format.
  • The password must be provided and have at least 8 characters.
  • The confirm password must be provided, have at least 8 characters and be the same as the password.

We will place all validators in the validators.js file.

src/components/loginForm/validators.js

export const emailValidator = email => {
  if (!email) {
    return "Email is required";
  } else if (!new RegExp(/\S+@\S+\.\S+/).test(email)) {
    return "Incorrect email format";
  }
  return "";
};

export const passwordValidator = password => {
  if (!password) {
    return "Password is required";
  } else if (password.length < 8) {
    return "Password must have a minimum 8 characters";
  }
  return "";
};

export const confirmPasswordValidator = (confirmPassword, form) => {
  if (!confirmPassword) {
    return "Confirm password is required";
  } else if (confirmPassword.length < 8) {
    return "Confirm password must have a minimum 8 characters";
  } else if (confirmPassword !== form.password) {
    return "Passwords do not match";
  }
  return "";
};

Note that the regex for testing the email value is very simple. It only checks if the email contains the @ sign and a dot between text. You might want to use a more complex validator like validator.js.

We are going to validate fields and show an error only if a user interacted with a field, or if they tried to submit the form. We will use an errors object that will have this shape:

{
  "<formFieldName>": {
    dirty: boolean,
    error: boolean,
    message: string
  } 
}

The dirty flag will indicate if a field was touched by a user or not. The error will be a flag indicating if there was an error, while the message string will contain the error message that should be displayed on the screen to inform the user about validation issues. We are going to put all the validation logic in a custom hook called useLoginFormValidator.

src/components/loginForm/hooks/useLoginFormValidator.js

import { useState } from "react";

import {
  emailValidator,
  passwordValidator,
  confirmPasswordValidator,
} from "../validators.js";

const touchErrors = errors => {
  return Object.entries(errors).reduce((acc, [field, fieldError]) => {
    acc[field] = {
      ...fieldError,
      dirty: true,
    };
    return acc;
  }, {});
};

export const useLoginFormValidator = form => {
  const [errors, setErrors] = useState({
    email: {
      dirty: false,
      error: false,
      message: "",
    },
    password: {
      dirty: false,
      error: false,
      message: "",
    },
    confirmPassword: {
      dirty: false,
      error: false,
      message: "",
    },
  });

  const validateForm = ({ form, field, errors, forceTouchErrors = false }) => {
    let isValid = true;

    // Create a deep copy of the errors
    const nextErrors = JSON.parse(JSON.stringify(errors));

    // Force validate all the fields
    if (forceTouchErrors) {
      nextErrors = touchErrors(errors);
    }

    const { email, password, confirmPassword } = form;

    if (nextErrors.email.dirty && (field ? field === "email" : true)) {
      const emailMessage = emailValidator(email, form);
      nextErrors.email.error = !!emailMessage;
      nextErrors.email.message = emailMessage;
      if (!!emailMessage) isValid = false;
    }

    if (nextErrors.password.dirty && (field ? field === "password" : true)) {
      const passwordMessage = passwordValidator(password, form);
      nextErrors.password.error = !!passwordMessage;
      nextErrors.password.message = passwordMessage;
      if (!!passwordMessage) isValid = false;
    }

    if (
      nextErrors.confirmPassword.dirty &&
      (field ? field === "confirmPassword" : true)
    ) {
      const confirmPasswordMessage = confirmPasswordValidator(
        confirmPassword,
        form
      );
      nextErrors.confirmPassword.error = !!confirmPasswordMessage;
      nextErrors.confirmPassword.message = confirmPasswordMessage;
      if (!!confirmPasswordMessage) isValid = false;
    }

    setErrors(nextErrors);

    return {
      isValid,
      errors: nextErrors,
    };
  };

  const onBlurField = e => {
    const field = e.target.name;
    const fieldError = errors[field];
    if (fieldError.dirty) return;

    const updatedErrors = {
      ...errors,
      [field]: {
        ...errors[field],
        dirty: true,
      },
    };

    validateForm({ form, field, errors: updatedErrors });
  };

  return {
    validateForm,
    onBlurField,
    errors,
  };
};

Let’s digest what’s happening here. First, we import the field validators we created previously. After the imports, we have the touchErrors function. It basically loops through the object and sets the dirty property of every error object to true. It is used to force validation on all fields when a user tries to submit the form. We do it in case a user didn’t interact with some of the fields. The touchErrors function is defined outside of the useLoginFormValidator, as it’s a pure function and doesn’t need to be inside of the hook. This way, it won’t be re-created every time the validator hook runs.

Inside of the useLoginFormValidator, which receives a form state as an argument, we first create the errors state:

const [errors, setErrors] = useState({
  email: {
    dirty: false,
    error: false,
    message: "",
  },
  password: {
    dirty: false,
    error: false,
    message: "",
  },
  confirmPassword: {
    dirty: false,
    error: false,
    message: "",
  },
});

Next, we have the validateForm function. It accepts an object with four properties:

  • form – the form state
  • field – the name of the form field that should be validated
  • errors – the errors object
  • forceTouchErrors – a Boolean flag indicating whether all fields should be set to dirty before validating the errors

For each field, the validator checks if the field was interacted with and compares it with the field argument. It would be wasteful to validate the whole form every time one input value changes. Therefore, the field value is used to check which validator should run.

For instance, if a user typed in something in the password field, only the passwordValidator would run. Each validator returns an error message string or an empty string if there are no errors. We use the value of the error message returned by a validator to set error and message on the field error object, and to update the isValid flag. Lastly, the setErrors method is called with the validation results and an object with isValid flag and errors are returned.

const validateForm = ({ form, field, errors, forceTouchErrors = false }) => {
  let isValid = true;
	
  // Create a deep copy of the errors
  let nextErrors = JSON.parse(JSON.stringify(errors))

  // Force validate all the fields
  if (forceTouchErrors) {
    nextErrors = touchErrors(errors);
  }

  const { email, password, confirmPassword } = form;

  if (nextErrors.email.dirty && (field ? field === "email" : true)) {
    const emailMessage = emailValidator(email, form);
    nextErrors.email.error = !!emailMessage;
    nextErrors.email.message = emailMessage;
    if (!!emailMessage) isValid = false;
  }

  if (nextErrors.password.dirty && (field ? field === "password" : true)) {
    const passwordMessage = passwordValidator(password, form);
    nextErrors.password.error = !!passwordMessage;
    nextErrors.password.message = passwordMessage;
    if (!!passwordMessage) isValid = false;
  }

  if (
    nextErrors.confirmPassword.dirty &&
    (field ? field === "confirmPassword" : true)
  ) {
    const confirmPasswordMessage = confirmPasswordValidator(
      confirmPassword,
      form
    );
    nextErrors.confirmPassword.error = !!confirmPasswordMessage;
    nextErrors.confirmPassword.message = confirmPasswordMessage;
    if (!!confirmPasswordMessage) isValid = false;
  }

  setErrors(nextErrors);

  return {
    isValid,
    errors: nextErrors,
  };
};

After the validateForm function, we have the onBlurField function. It checks if the field that was blurred is already dirty. If it is, then it returns early, because there is no point in updating the errors state. However, if it is not dirty, the errors object will be updated accordingly and validation for the field will be triggered.

const onBlurField = e => {
  const field = e.target.name;
  const fieldError = errors[field];
  if (fieldError.dirty) return;

  const updatedErrors = {
    ...errors,
    [field]: {
      ...errors[field],
      dirty: true,
    },
  };

  validateForm({ form, field, errors: updatedErrors });
};

Finally, the useLoginFormValidator returns an object with validateForm, onBlurField and errors properties.

return {
  validateForm,
  onBlurField,
  errors,
}

That’s it for the useLoginFormValidator. Let’s import and use it in the LoginForm component.

src/components/loginForm/LoginForm.jsx

import { useState } from "react";
import clsx from "clsx";
import styles from "./LoginForm.module.css";
import { useLoginFormValidator } from "./hooks/useLoginFormValidator";

const LoginForm = props => {
  const [form, setForm] = useState({
    email: "",
    password: "",
    confirmPassword: "",
  });
  
  const { errors, validateForm, onBlurField } = useLoginFormValidator(form);

  const onUpdateField = e => {
    const field = e.target.name;
    const nextFormState = {
      ...form,
      [field]: e.target.value,
    };
    setForm(nextFormState);
    if (errors[field].dirty)
      validateForm({
        form: nextFormState,
        errors,
        field,
      });
  };

  const onSubmitForm = e => {
    e.preventDefault();
    const { isValid } = validateForm({ form, errors, forceTouchErrors: true });
    if (!isValid) return;
    alert(JSON.stringify(form, null, 2));
  };

  return (
    <form className={styles.form} onSubmit={onSubmitForm}>
      <div className={styles.formGroup}>
        <label className={styles.formLabel}>Email</label>
        <input
          className={clsx(
            styles.formField,
            errors.email.dirty && errors.email.error && styles.formFieldError
          )}
          type="text"
          aria-label="Email field"
          name="email"
          value={form.email}
          onChange={onUpdateField}
          onBlur={onBlurField}
        />
        {errors.email.dirty && errors.email.error ? (
          <p className={styles.formFieldErrorMessage}>{errors.email.message}</p>
        ) : null}
      </div>
      <div className={styles.formGroup}>
        <label className={styles.formLabel}>Password</label>
        <input
          className={clsx(
            styles.formField,
            errors.password.dirty &&
              errors.password.error &&
              styles.formFieldError
          )}
          type="password"
          aria-label="Password field"
          name="password"
          value={form.password}
          onChange={onUpdateField}
          onBlur={onBlurField}
        />
        {errors.password.dirty && errors.password.error ? (
          <p className={styles.formFieldErrorMessage}>
            {errors.password.message}
          </p>
        ) : null}
      </div>
      <div className={styles.formGroup}>
        <label className={styles.formLabel}>Confirm Password</label>
        <input
          className={clsx(
            styles.formField,
            errors.confirmPassword.dirty &&
              errors.confirmPassword.error &&
              styles.formFieldError
          )}
          type="password"
          aria-label="Confirm password field"
          name="confirmPassword"
          value={form.confirmPassword}
          onChange={onUpdateField}
          onBlur={onBlurField}
        />
        {errors.confirmPassword.dirty && errors.confirmPassword.error ? (
          <p className={styles.formFieldErrorMessage}>
            {errors.confirmPassword.message}
          </p>
        ) : null}
      </div>
      <div className={styles.formActions}>
        <button className={styles.formSubmitBtn} type="submit">
          Login
        </button>
      </div>
    </form>
  );
};

export default LoginForm;

There are a few changes we had to make in the LoginForm component. First, we imported the clsx helper and the useLoginFormValidator hook and initialized the latter after the form state is created.

const { errors, validateForm, onBlurField } = useLoginFormValidator(form);

We also had to update both onUpdateField and onSubmitForm functions. In the onUpdateField, after calling the setForm method, we check if the field we are updating is dirty. If it is, then we trigger form validation for it.

const onUpdateField = e => {
  const field = e.target.name;
  const nextFormState = {
    ...form,
    [field]: e.target.value,
  };
  setForm(nextFormState);
  if (errors[field].dirty)
    validateForm({
      form: nextFormState,
      errors,
      field,
    });
};

In the onSubmitField, we first run the validateForm method with forceTouchErrors set to true. If the form is not valid, we just bail out. Otherwise, we proceed and the alert will be shown.

const onSubmitForm = e => {
  e.preventDefault();
  const { isValid } = validateForm({ form, errors, forceTouchErrors: true });
  if (!isValid) return;
  alert(JSON.stringify(form, null, 2));
};

The last thing we had to update was the JSX markup. Each input field will receive formFieldError class if its field was touched and there is an error. We also passed onBlurField method as the onBlur prop. Besides that, if there is an error, a paragraph element with the error message is rendered.

<div className={styles.formGroup}>
  <label className={styles.formLabel}>Email</label>
  <input
    className={clsx(
      styles.formField,
      errors.email.dirty && errors.email.error && styles.formFieldError
    )}
    type="text"
    aria-label="Email field"
    name="email"
    value={form.email}
    onChange={onUpdateField}
    onBlur={onBlurField}
    />
  {errors.email.dirty && errors.email.error ? (
    <p className={styles.formFieldErrorMessage}>{errors.email.message}</p>
  ) : null}
</div>
<div className={styles.formGroup}>
  <label className={styles.formLabel}>Password</label>
  <input
    className={clsx(
      styles.formField,
      errors.password.dirty &&
      errors.password.error &&
      styles.formFieldError
    )}
    type="password"
    aria-label="Password field"
    name="password"
    value={form.password}
    onChange={onUpdateField}
    onBlur={onBlurField}
    />
  {errors.password.dirty && errors.password.error ? (
    <p className={styles.formFieldErrorMessage}>
      {errors.password.message}
    </p>
  ) : null}
</div>
<div className={styles.formGroup}>
  <label className={styles.formLabel}>Confirm Password</label>
  <input
    className={clsx(
      styles.formField,
      errors.confirmPassword.dirty &&
      errors.confirmPassword.error &&
      styles.formFieldError
    )}
    type="password"
    aria-label="Confirm password field"
    name="confirmPassword"
    value={form.confirmPassword}
    onChange={onUpdateField}
    onBlur={onBlurField}
    />
  {errors.confirmPassword.dirty && errors.confirmPassword.error ? (
    <p className={styles.formFieldErrorMessage}>
      {errors.confirmPassword.message}
    </p>
  ) : null}
</div>

That’s it! Below you can see a GIF showing our React login form with validation in action.

React login form with hooks and validation

Note that in this article we have added only client-side validation. Client-side validation is purely for enhancing user experience. You should always add server-side validation and never rely on the client-side validation, as it can be easily bypassed!

Summary

We have covered how to create and validate a login form using React Hooks.

We have built the form from scratch and it can work well for smaller applications, but building things from scratch isn’t always the best solution. If your application has complex forms, it might be a good idea to use a proper form library, such as KendoReact Form component, instead of creating everything yourself.

A good form library can make it much easier to create complex forms by abstracting a lot of form state handling and validation logic and enforcing consistent implementation. KendoReact Form not only makes creating forms a breeze, but also comes with a complete library of React components and an award-winning technical support.

If you’re curious to learn more about how the KendoReact Form Library can make a React developer’s life easier, check out this blog: 5 Reasons To Choose the KendoReact Form Library.


Thomas Findlay-2
About the Author

Thomas Findlay

Thomas Findlay is a 5-star rated mentor, full-stack developer, consultant, technical writer and the author of “React - The Road To Enterprise” and “Vue - The Road To Enterprise.” He works with many different technologies such as JavaScript, Vue, React, React Native, Node.js, Python, PHP and more. Thomas has worked with developers and teams from beginner to advanced and helped them build and scale their applications and products. Check out his Codementor page, and you can also find him on Twitter.

Related Posts

Comments

Comments are disabled in preview mode.