DEV Community

Cover image for Form validation in React using the useReducer Hook
collegewap
collegewap

Posted on • Originally published at codingdeft.com

Form validation in React using the useReducer Hook

Form Validation Library Comparison

There are a lot of libraries out there for validating forms in react.
Redux-Form, Formik, react-final-form are few among them.

While these libraries are cool and they help in validating the forms to a great extent, they come with a catch: they add up to bundle size.
Let's see a quick comparison between these libraries:

Redux Form

Redux form cannot function on its own.
It has 2 additional dependencies redux and react-redux.
If you are already using redux in your application, then you have already installed redux and react-redux packages.
You can see that from the bundle phobia analysis given below that it adds 35 kB to your bundle size, while react itself is just about 38.5 kB.

redux form stats

Formik

Formik can function on its own without any additional packages to be installed along with it.
The bundle size is 15 kB, which is considerably smaller than that of redux-form.

formik stats

React Final Form

React final form is created by the author (@erikras) of redux-form.
It is a wrapper around the final-form core, which has no dependencies.
Since one of the goals behind react final forms was to reduce bundle size, it weighs 8.5 kB gzipped.

react final form

Now let's see how we can do form validation without depending upon these libraries:

Setting up the project

Create a new react project using the following command:

npx create-react-app react-form-validation
Enter fullscreen mode Exit fullscreen mode

Update App.js with the following code:

import React from "react"
import "./App.css"

function App() {
  return (
    <div className="App">
      <h1 className="title">Sign Up</h1>
      <form>
        <div className="input_wrapper">
          <label htmlFor="name">Name:</label>
          <input type="text" name="name" id="name" />
        </div>
        <div className="input_wrapper">
          <label htmlFor="email">Email:</label>
          <input type="email" name="email" id="email" />
        </div>
        <div className="input_wrapper">
          <label htmlFor="password">Password:</label>
          <input type="password" name="password" id="password" />
        </div>
        <div className="input_wrapper">
          <label htmlFor="mobile">Mobile:</label>
          <input type="text" name="mobile" id="mobile" />
        </div>
        <div className="input_wrapper">
          <label className="toc">
            <input type="checkbox" name="terms" /> Accept terms and conditions
          </label>
        </div>
        <div className="input_wrapper">
          <input className="submit_btn" type="submit" value="Sign Up" />
        </div>
      </form>
    </div>
  )
}

export default App
Enter fullscreen mode Exit fullscreen mode

Here we have created a simple sign up form with few fields. Now to style these fields let's add some css to App.css:

.App {
  max-width: 300px;
  margin: 1rem auto;
}
.title {
  text-align: center;
}
.input_wrapper {
  display: flex;
  flex-direction: column;
  margin-bottom: 0.5rem;
}
.input_wrapper label {
  font-size: 1.1rem;
}
.input_wrapper input {
  margin-top: 0.4rem;
  font-size: 1.1rem;
}
.submit_btn {
  cursor: pointer;
  padding: 0.2rem;
}
.toc,
.toc input {
  cursor: pointer;
}
Enter fullscreen mode Exit fullscreen mode

Now if you open the app, you should see our basic form set up:

basic form

Binding the form value with the state

Now that we have the form ready, let's bind the input values with the state

import React, { useReducer } from "react"
import "./App.css"

/**
 * The initial state of the form
 * value: stores the value of the input field
 * touched: indicates whether the user has tried to input anything in the field
 * hasError: determines whether the field has error.
 *           Defaulted to true since all fields are mandatory and are empty on page load.
 * error: stores the error message
 * isFormValid: Stores the validity of the form at any given time.
 */
const initialState = {
  name: { value: "", touched: false, hasError: true, error: "" },
  email: { value: "", touched: false, hasError: true, error: "" },
  password: { value: "", touched: false, hasError: true, error: "" },
  mobile: { value: "", touched: false, hasError: true, error: "" },
  terms: { value: false, touched: false, hasError: true, error: "" },
  isFormValid: false,
}

/**
 * Reducer which will perform form state update
 */
const formsReducer = (state, action) => {
  return state
}

function App() {
  const [formState, dispatch] = useReducer(formsReducer, initialState)

  return (
    <div className="App">
      <h1 className="title">Sign Up</h1>
      <form>
        <div className="input_wrapper">
          <label htmlFor="name">Name:</label>
          <input
            type="text"
            name="name"
            id="name"
            value={formState.name.value}
          />
        </div>
        <div className="input_wrapper">
          <label htmlFor="email">Email:</label>
          <input
            type="email"
            name="email"
            id="email"
            value={formState.email.value}
          />
        </div>
        <div className="input_wrapper">
          <label htmlFor="password">Password:</label>
          <input
            type="password"
            name="password"
            id="password"
            value={formState.password.value}
          />
        </div>
        <div className="input_wrapper">
          <label htmlFor="mobile">Mobile:</label>
          <input
            type="text"
            name="mobile"
            id="mobile"
            value={formState.mobile.value}
          />
        </div>
        <div className="input_wrapper">
          <label className="toc">
            <input
              type="checkbox"
              name="terms"
              checked={formState.terms.value}
            />{" "}
            Accept terms and conditions
          </label>
        </div>
        <div className="input_wrapper">
          <input className="submit_btn" type="submit" value="Sign Up" />
        </div>
      </form>
    </div>
  )
}

export default App
Enter fullscreen mode Exit fullscreen mode

In the above code,

  • We have introduced a new object initialState, which stores the initial state of the form.
  • We also have defined a reducer function named formsReducer, which does nothing as of now, but we will have the logic inside it to update the form state.
  • We have introduced useReducer hook, which returns the current form state and a dispatch function, which will be used to fire form update actions.

If you try to enter any values in the form now,
you will not be able to update it because we don't have any handler functions, which will update our state.

Adding form handler

Create a folder called lib in src directory and a file named formUtils.js inside it.
This file will have the handler functions which can be reused for other forms.

export const UPDATE_FORM = "UPDATE_FORM"

/**
 * Triggered every time the value of the form changes
 */
export const onInputChange = (name, value, dispatch, formState) => {
  dispatch({
    type: UPDATE_FORM,
    data: {
      name,
      value,
      hasError: false,
      error: "",
      touched: false,
      isFormValid: true,
    },
  })
}
Enter fullscreen mode Exit fullscreen mode

Here you could see that we are dispatching the UPDATE_FORM action with the value that is being passed to the handler.
As of now, we are setting hasError to false and isFormValid to true since we are yet to write the validation logic.

Now in the App.js file, update the reducer function to handle the UPDATE_FORM action.
Here we are updating the value of the corresponding input field using the name as the key.

//...

import { UPDATE_FORM, onInputChange } from "./lib/formUtils"

//...
const formsReducer = (state, action) => {
  switch (action.type) {
    case UPDATE_FORM:
      const { name, value, hasError, error, touched, isFormValid } = action.data
      return {
        ...state,
        // update the state of the particular field,
        // by retaining the state of other fields
        [name]: { ...state[name], value, hasError, error, touched },
        isFormValid,
      }
    default:
      return state
  }
}
Enter fullscreen mode Exit fullscreen mode

Now bind the onInputChange handler we imported above with the input field for name:

<div className="input_wrapper">
  <label htmlFor="name">Name:</label>
  <input
    type="text"
    name="name"
    id="name"
    value={formState.name.value}
    onChange={e => {
      onInputChange("name", e.target.value, dispatch, formState)
    }}
  />
</div>
Enter fullscreen mode Exit fullscreen mode

Now you should be able to edit the name field.
Now its time to write the validation logic!

Adding Validations

Add a function called validateInput to formUtils.js. Inside this function, we will write validations for all the fields.

export const validateInput = (name, value) => {
  let hasError = false,
    error = ""
  switch (name) {
    case "name":
      if (value.trim() === "") {
        hasError = true
        error = "Name cannot be empty"
      } else if (!/^[a-zA-Z ]+$/.test(value)) {
        hasError = true
        error = "Invalid Name. Avoid Special characters"
      } else {
        hasError = false
        error = ""
      }
      break
    default:
      break
  }
  return { hasError, error }
}
Enter fullscreen mode Exit fullscreen mode

Here you can see that in the first if condition, we are checking for empty value since name field is mandatory.
In the second if condition,
we are using RegEx to validate if the name contains any other characters other than the English alphabets and spaces.

We assume that all the names are in English.
If you have a user base in regions where the name contains characters outside the English alphabet, you can update the Regex accordingly.

Now update the onInputChange function to make use of the validation function:

export const onInputChange = (name, value, dispatch, formState) => {
  const { hasError, error } = validateInput(name, value)
  let isFormValid = true

  for (const key in formState) {
    const item = formState[key]
    // Check if the current field has error
    if (key === name && hasError) {
      isFormValid = false
      break
    } else if (key !== name && item.hasError) {
      // Check if any other field has error
      isFormValid = false
      break
    }
  }

  dispatch({
    type: UPDATE_FORM,
    data: { name, value, hasError, error, touched: false, isFormValid },
  })
}
Enter fullscreen mode Exit fullscreen mode

You will also see that we are looping through the formState to check
if any of the field is having error to determine the overall validity of the form.

Now let's see if our validation logic works fine. Since we aren't displaying the error message yet, let's log the formState and see the values.

When an invalid name is entered

Invalid Name Validation

Use console.table({"name state": formState.name}); for displaying the values of an object in tabular format

When the name is kept empty

Empty Name Validation

When a valid name is entered

valid Name

You might see that the loggers are getting printed twice each time you type a character:
this is expected to happen in Strict Mode in the development environment.
This is actually to make sure that there are no side effects inside the reducer functions.

Displaying error message

Before showing the error message, let's add another handler function to our formUtils.js

//...
export const onFocusOut = (name, value, dispatch, formState) => {
  const { hasError, error } = validateInput(name, value)
  let isFormValid = true
  for (const key in formState) {
    const item = formState[key]
    if (key === name && hasError) {
      isFormValid = false
      break
    } else if (key !== name && item.hasError) {
      isFormValid = false
      break
    }
  }

  dispatch({
    type: UPDATE_FORM,
    data: { name, value, hasError, error, touched: true, isFormValid },
  })
}
Enter fullscreen mode Exit fullscreen mode

You might observe that the onFocusOut function is very similar to onInputChange,
except that we pass touched as true in case of onFocusOut.
The reason for having additional handler function, which will be bound with the onBlur event of the input is
to show the error messages only when the user finishes typing and moves to the next field.

Now that we have the error message stored in our state, let's display it:

//...
import { UPDATE_FORM, onInputChange, onFocusOut } from "./lib/formUtils"

//...
function App() {
  const [formState, dispatch] = useReducer(formsReducer, initialState)

  return (
    <div className="App">
      <h1 className="title">Sign Up</h1>
      <form>
        <div className="input_wrapper">
          <label htmlFor="name">Name:</label>
          <input
            type="text"
            name="name"
            id="name"
            value={formState.name.value}
            onChange={e => {
              onInputChange("name", e.target.value, dispatch, formState)
            }}
            onBlur={e => {
              onFocusOut("name", e.target.value, dispatch, formState)
            }}
          />
          {formState.name.touched && formState.name.hasError && (
            <div className="error">{formState.name.error}</div>
          )}
        </div>
        {/* ... */}
      </form>
    </div>
  )
}

export default App
Enter fullscreen mode Exit fullscreen mode

You will see that we have added onBlur handler and we are displaying the error message whenever the form is touched and has errors.

Now let's add some styling for the error message in App.css

/*...*/
.error {
  margin-top: 0.25rem;
  color: #f65157;
}
Enter fullscreen mode Exit fullscreen mode

Now if you type an invalid name or leave the field empty, you will see the error message:

Name Validations

Adding validation to other fields

Now let's add validation to other fields

Update the validateInput function inside formUtils.js:

export const validateInput = (name, value) => {
  let hasError = false,
    error = ""
  switch (name) {
    case "name":
      if (value.trim() === "") {
        hasError = true
        error = "Name cannot be empty"
      } else if (!/^[a-zA-Z ]+$/.test(value)) {
        hasError = true
        error = "Invalid Name. Avoid Special characters"
      } else {
        hasError = false
        error = ""
      }
      break
    case "email":
      if (value.trim() === "") {
        hasError = true
        error = "Email cannot be empty"
      } else if (
        !/^[a-z0-9!#$%&'*+/=?^_`{|}~-]+(?:\.[a-z0-9!#$%&'*+/=?^_`{|}~-]+)*@(?:[a-z0-9](?:[a-z0-9-]*[a-z0-9])?\.)+[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/.test(
          value
        )
      ) {
        hasError = true
        error = "Invalid Email"
      } else {
        hasError = false
        error = ""
      }
      break
    case "password":
      if (value.trim() === "") {
        hasError = true
        error = "Password cannot be empty"
      } else if (value.trim().length < 8) {
        hasError = true
        error = "Password must have at least 8 characters"
      } else {
        hasError = false
        error = ""
      }
      break
    case "mobile":
      if (value.trim() === "") {
        hasError = true
        error = "Mobile cannot be empty"
      } else if (!/^[0-9]{10}$/.test(value)) {
        hasError = true
        error = "Invalid Mobile Number. Use 10 digits only"
      } else {
        hasError = false
        error = ""
      }
      break
    case "terms":
      if (!value) {
        hasError = true
        error = "You must accept terms and conditions"
      } else {
        hasError = false
        error = ""
      }
      break
    default:
      break
  }
  return { hasError, error }
}
Enter fullscreen mode Exit fullscreen mode

Note that we have added validation password to have minimum of 8 characters, mobile number to have 10 digits.
Also, you might be wondering about the really long RegEx used for email validation.
You can read more about email validation at emailregex.com.

Now let's bind them to the form:

//...

function App() {
  const [formState, dispatch] = useReducer(formsReducer, initialState)

  return (
    <div className="App">
      <h1 className="title">Sign Up</h1>
      <form>
        <div className="input_wrapper">
          <label htmlFor="name">Name:</label>
          <input
            type="text"
            name="name"
            id="name"
            value={formState.name.value}
            onChange={e => {
              onInputChange("name", e.target.value, dispatch, formState)
            }}
            onBlur={e => {
              onFocusOut("name", e.target.value, dispatch, formState)
            }}
          />
          {formState.name.touched && formState.name.hasError && (
            <div className="error">{formState.name.error}</div>
          )}
        </div>
        <div className="input_wrapper">
          <label htmlFor="email">Email:</label>
          <input
            type="email"
            name="email"
            id="email"
            value={formState.email.value}
            onChange={e => {
              onInputChange("email", e.target.value, dispatch, formState)
            }}
            onBlur={e => {
              onFocusOut("email", e.target.value, dispatch, formState)
            }}
          />
          {formState.email.touched && formState.email.hasError && (
            <div className="error">{formState.email.error}</div>
          )}
        </div>
        <div className="input_wrapper">
          <label htmlFor="password">Password:</label>
          <input
            type="password"
            name="password"
            id="password"
            value={formState.password.value}
            onChange={e => {
              onInputChange("password", e.target.value, dispatch, formState)
            }}
            onBlur={e => {
              onFocusOut("password", e.target.value, dispatch, formState)
            }}
          />
          {formState.password.touched && formState.password.hasError && (
            <div className="error">{formState.password.error}</div>
          )}
        </div>
        <div className="input_wrapper">
          <label htmlFor="mobile">Mobile:</label>
          <input
            type="text"
            name="mobile"
            id="mobile"
            value={formState.mobile.value}
            onChange={e => {
              onInputChange("mobile", e.target.value, dispatch, formState)
            }}
            onBlur={e => {
              onFocusOut("mobile", e.target.value, dispatch, formState)
            }}
          />
          {formState.mobile.touched && formState.mobile.hasError && (
            <div className="error">{formState.mobile.error}</div>
          )}
        </div>
        <div className="input_wrapper">
          <label className="toc">
            <input
              type="checkbox"
              name="terms"
              checked={formState.terms.value}
              onChange={e => {
                onFocusOut("terms", e.target.checked, dispatch, formState)
              }}
            />
            Accept terms and conditions
          </label>
          {formState.terms.touched && formState.terms.hasError && (
            <div className="error">{formState.terms.error}</div>
          )}
        </div>
        <div className="input_wrapper">
          <input className="submit_btn" type="submit" value="Sign Up" />
        </div>
      </form>
    </div>
  )
}

export default App
Enter fullscreen mode Exit fullscreen mode

Now if you test the application, you will see all validations in place:

All Validations

Though we have all the validations, we are not validating the form if the user clicks on submit without filling any of the fields.

Adding form level validation

For the last time, let's add the form level validation

import React, { useReducer, useState } from "react"
import "./App.css"
import {
  UPDATE_FORM,
  onInputChange,
  onFocusOut,
  validateInput,
} from "./lib/formUtils"

//...

function App() {
  const [formState, dispatch] = useReducer(formsReducer, initialState)

  const [showError, setShowError] = useState(false)

  const formSubmitHandler = e => {
    e.preventDefault() //prevents the form from submitting

    let isFormValid = true

    for (const name in formState) {
      const item = formState[name]
      const { value } = item
      const { hasError, error } = validateInput(name, value)
      if (hasError) {
        isFormValid = false
      }
      if (name) {
        dispatch({
          type: UPDATE_FORM,
          data: {
            name,
            value,
            hasError,
            error,
            touched: true,
            isFormValid,
          },
        })
      }
    }
    if (!isFormValid) {
      setShowError(true)
    } else {
      //Logic to submit the form to backend
    }

    // Hide the error message after 5 seconds
    setTimeout(() => {
      setShowError(false)
    }, 5000)
  }

  return (
    <div className="App">
      <h1 className="title">Sign Up</h1>
      {showError && !formState.isFormValid && (
        <div className="form_error">Please fill all the fields correctly</div>
      )}
      <form onSubmit={e => formSubmitHandler(e)}>
        <div className="input_wrapper">{/* ... */}</div>
      </form>
    </div>
  )
}

export default App
Enter fullscreen mode Exit fullscreen mode

We have added a block error message which will be displayed when the user submits the form and as long as the form is invalid.

Let's add some css to style the error message in App.css:

/* ... */

.form_error {
  color: #721c24;
  background-color: #f8d7da;
  border-color: #f5c6cb;
  padding: 0.5rem 1.25rem;
  border: 1px solid transparent;
  border-radius: 0.25rem;
  margin: 1rem 0;
}
Enter fullscreen mode Exit fullscreen mode

Now if you click on the submit button without filling the form you should see:

Block Validation Message

Analyzing the bundle size

Let's see if we were successful in reducing the bundle size by writing our own implementation of form validation.
To do so, first install webpack-bundle-analyzer package as a dev dependency:

yarn add webpack-bundle-analyzer -D
Enter fullscreen mode Exit fullscreen mode

Create a file named analyze.js in the root directory with the following content:

// script to enable webpack-bundle-analyzer
process.env.NODE_ENV = "production"
const webpack = require("webpack")
const BundleAnalyzerPlugin = require("webpack-bundle-analyzer")
  .BundleAnalyzerPlugin
const webpackConfigProd = require("react-scripts/config/webpack.config")(
  "production"
)

webpackConfigProd.plugins.push(new BundleAnalyzerPlugin())

// actually running compilation and waiting for plugin to start explorer
webpack(webpackConfigProd, (err, stats) => {
  if (err || stats.hasErrors()) {
    console.error(err)
  }
})
Enter fullscreen mode Exit fullscreen mode

Run the following command in the terminal:

node analyze.js
Enter fullscreen mode Exit fullscreen mode

Now a browser window will automatically open with the URL http://127.0.0.1:8888

If you see the bundle size, you will find that our application including form validation utils and css is just 1.67kB gzipped!

Bundle Size

Conclusion

While the form validation libraries have a lot of advantages like
it lets you write less code and if there are a lot of forms in your application, it pays for itself.
But if you are having a simple form and you are concerned about bundle size you can always go for this custom implementation.
Also, if the form is very complex, then again you will have to go for custom implementation
since the form validation libraries might not cover all your use cases.

Source code and Demo

You can view the complete source code here and a demo here.

Top comments (0)