Skip to content
react

Form validation in React using the useReducer Hook

Sep 4, 2020Abhishek EH15 Min Read
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:

1npx create-react-app react-form-validation

Update App.js with the following code:

App.js
1import React from "react"
2import "./App.css"
3
4function App() {
5 return (
6 <div className="App">
7 <h1 className="title">Sign Up</h1>
8 <form>
9 <div className="input_wrapper">
10 <label htmlFor="name">Name:</label>
11 <input type="text" name="name" id="name" />
12 </div>
13 <div className="input_wrapper">
14 <label htmlFor="email">Email:</label>
15 <input type="email" name="email" id="email" />
16 </div>
17 <div className="input_wrapper">
18 <label htmlFor="password">Password:</label>
19 <input type="password" name="password" id="password" />
20 </div>
21 <div className="input_wrapper">
22 <label htmlFor="mobile">Mobile:</label>
23 <input type="text" name="mobile" id="mobile" />
24 </div>
25 <div className="input_wrapper">
26 <label className="toc">
27 <input type="checkbox" name="terms" /> Accept terms and conditions
28 </label>
29 </div>
30 <div className="input_wrapper">
31 <input className="submit_btn" type="submit" value="Sign Up" />
32 </div>
33 </form>
34 </div>
35 )
36}
37
38export default App

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

App.css
1.App {
2 max-width: 300px;
3 margin: 1rem auto;
4}
5.title {
6 text-align: center;
7}
8.input_wrapper {
9 display: flex;
10 flex-direction: column;
11 margin-bottom: 0.5rem;
12}
13.input_wrapper label {
14 font-size: 1.1rem;
15}
16.input_wrapper input {
17 margin-top: 0.4rem;
18 font-size: 1.1rem;
19}
20.submit_btn {
21 cursor: pointer;
22 padding: 0.2rem;
23}
24.toc,
25.toc input {
26 cursor: pointer;
27}

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

App.js
1import React, { useReducer } from "react"
2import "./App.css"
3
4/**
5 * The initial state of the form
6 * value: stores the value of the input field
7 * touched: indicates whether the user has tried to input anything in the field
8 * hasError: determines whether the field has error.
9 * Defaulted to true since all fields are mandatory and are empty on page load.
10 * error: stores the error message
11 * isFormValid: Stores the validity of the form at any given time.
12 */
13const initialState = {
14 name: { value: "", touched: false, hasError: true, error: "" },
15 email: { value: "", touched: false, hasError: true, error: "" },
16 password: { value: "", touched: false, hasError: true, error: "" },
17 mobile: { value: "", touched: false, hasError: true, error: "" },
18 terms: { value: false, touched: false, hasError: true, error: "" },
19 isFormValid: false,
20}
21
22/**
23 * Reducer which will perform form state update
24 */
25const formsReducer = (state, action) => {
26 return state
27}
28
29function App() {
30 const [formState, dispatch] = useReducer(formsReducer, initialState)
31
32 return (
33 <div className="App">
34 <h1 className="title">Sign Up</h1>
35 <form>
36 <div className="input_wrapper">
37 <label htmlFor="name">Name:</label>
38 <input
39 type="text"
40 name="name"
41 id="name"
42 value={formState.name.value}
43 />
44 </div>
45 <div className="input_wrapper">
46 <label htmlFor="email">Email:</label>
47 <input
48 type="email"
49 name="email"
50 id="email"
51 value={formState.email.value}
52 />
53 </div>
54 <div className="input_wrapper">
55 <label htmlFor="password">Password:</label>
56 <input
57 type="password"
58 name="password"
59 id="password"
60 value={formState.password.value}
61 />
62 </div>
63 <div className="input_wrapper">
64 <label htmlFor="mobile">Mobile:</label>
65 <input
66 type="text"
67 name="mobile"
68 id="mobile"
69 value={formState.mobile.value}
70 />
71 </div>
72 <div className="input_wrapper">
73 <label className="toc">
74 <input
75 type="checkbox"
76 name="terms"
77 checked={formState.terms.value}
78 />{" "}
79 Accept terms and conditions
80 </label>
81 </div>
82 <div className="input_wrapper">
83 <input className="submit_btn" type="submit" value="Sign Up" />
84 </div>
85 </form>
86 </div>
87 )
88}
89
90export default App

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.

formUtils.js
1export const UPDATE_FORM = "UPDATE_FORM"
2
3/**
4 * Triggered every time the value of the form changes
5 */
6export const onInputChange = (name, value, dispatch, formState) => {
7 dispatch({
8 type: UPDATE_FORM,
9 data: {
10 name,
11 value,
12 hasError: false,
13 error: "",
14 touched: false,
15 isFormValid: true,
16 },
17 })
18}

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.

formReducer function
1//...
2
3import { UPDATE_FORM, onInputChange } from "./lib/formUtils"
4
5//...
6const formsReducer = (state, action) => {
7 switch (action.type) {
8 case UPDATE_FORM:
9 const { name, value, hasError, error, touched, isFormValid } = action.data
10 return {
11 ...state,
12 // update the state of the particular field,
13 // by retaining the state of other fields
14 [name]: { ...state[name], value, hasError, error, touched },
15 isFormValid,
16 }
17 default:
18 return state
19 }
20}

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

1<div className="input_wrapper">
2 <label htmlFor="name">Name:</label>
3 <input
4 type="text"
5 name="name"
6 id="name"
7 value={formState.name.value}
8 onChange={e => {
9 onInputChange("name", e.target.value, dispatch, formState)
10 }}
11 />
12</div>

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.

1export const validateInput = (name, value) => {
2 let hasError = false,
3 error = ""
4 switch (name) {
5 case "name":
6 if (value.trim() === "") {
7 hasError = true
8 error = "Name cannot be empty"
9 } else if (!/^[a-zA-Z ]+$/.test(value)) {
10 hasError = true
11 error = "Invalid Name. Avoid Special characters"
12 } else {
13 hasError = false
14 error = ""
15 }
16 break
17 default:
18 break
19 }
20 return { hasError, error }
21}

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:

1export const onInputChange = (name, value, dispatch, formState) => {
2 const { hasError, error } = validateInput(name, value)
3 let isFormValid = true
4
5 for (const key in formState) {
6 const item = formState[key]
7 // Check if the current field has error
8 if (key === name && hasError) {
9 isFormValid = false
10 break
11 } else if (key !== name && item.hasError) {
12 // Check if any other field has error
13 isFormValid = false
14 break
15 }
16 }
17
18 dispatch({
19 type: UPDATE_FORM,
20 data: { name, value, hasError, error, touched: false, isFormValid },
21 })
22}

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

1//...
2export const onFocusOut = (name, value, dispatch, formState) => {
3 const { hasError, error } = validateInput(name, value)
4 let isFormValid = true
5 for (const key in formState) {
6 const item = formState[key]
7 if (key === name && hasError) {
8 isFormValid = false
9 break
10 } else if (key !== name && item.hasError) {
11 isFormValid = false
12 break
13 }
14 }
15
16 dispatch({
17 type: UPDATE_FORM,
18 data: { name, value, hasError, error, touched: true, isFormValid },
19 })
20}

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:

App.js
1//...
2import { UPDATE_FORM, onInputChange, onFocusOut } from "./lib/formUtils"
3
4//...
5function App() {
6 const [formState, dispatch] = useReducer(formsReducer, initialState)
7
8 return (
9 <div className="App">
10 <h1 className="title">Sign Up</h1>
11 <form>
12 <div className="input_wrapper">
13 <label htmlFor="name">Name:</label>
14 <input
15 type="text"
16 name="name"
17 id="name"
18 value={formState.name.value}
19 onChange={e => {
20 onInputChange("name", e.target.value, dispatch, formState)
21 }}
22 onBlur={e => {
23 onFocusOut("name", e.target.value, dispatch, formState)
24 }}
25 />
26 {formState.name.touched && formState.name.hasError && (
27 <div className="error">{formState.name.error}</div>
28 )}
29 </div>
30 {/* ... */}
31 </form>
32 </div>
33 )
34}
35
36export default App

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

App.css
1/*...*/
2.error {
3 margin-top: 0.25rem;
4 color: #f65157;
5}

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:

formUtils.js
1export const validateInput = (name, value) => {
2 let hasError = false,
3 error = ""
4 switch (name) {
5 case "name":
6 if (value.trim() === "") {
7 hasError = true
8 error = "Name cannot be empty"
9 } else if (!/^[a-zA-Z ]+$/.test(value)) {
10 hasError = true
11 error = "Invalid Name. Avoid Special characters"
12 } else {
13 hasError = false
14 error = ""
15 }
16 break
17 case "email":
18 if (value.trim() === "") {
19 hasError = true
20 error = "Email cannot be empty"
21 } else if (
22 !/^[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(
23 value
24 )
25 ) {
26 hasError = true
27 error = "Invalid Email"
28 } else {
29 hasError = false
30 error = ""
31 }
32 break
33 case "password":
34 if (value.trim() === "") {
35 hasError = true
36 error = "Password cannot be empty"
37 } else if (value.trim().length < 8) {
38 hasError = true
39 error = "Password must have at least 8 characters"
40 } else {
41 hasError = false
42 error = ""
43 }
44 break
45 case "mobile":
46 if (value.trim() === "") {
47 hasError = true
48 error = "Mobile cannot be empty"
49 } else if (!/^[0-9]{10}$/.test(value)) {
50 hasError = true
51 error = "Invalid Mobile Number. Use 10 digits only"
52 } else {
53 hasError = false
54 error = ""
55 }
56 break
57 case "terms":
58 if (!value) {
59 hasError = true
60 error = "You must accept terms and conditions"
61 } else {
62 hasError = false
63 error = ""
64 }
65 break
66 default:
67 break
68 }
69 return { hasError, error }
70}

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:

App.js
1//...
2
3function App() {
4 const [formState, dispatch] = useReducer(formsReducer, initialState)
5
6 return (
7 <div className="App">
8 <h1 className="title">Sign Up</h1>
9 <form>
10 <div className="input_wrapper">
11 <label htmlFor="name">Name:</label>
12 <input
13 type="text"
14 name="name"
15 id="name"
16 value={formState.name.value}
17 onChange={e => {
18 onInputChange("name", e.target.value, dispatch, formState)
19 }}
20 onBlur={e => {
21 onFocusOut("name", e.target.value, dispatch, formState)
22 }}
23 />
24 {formState.name.touched && formState.name.hasError && (
25 <div className="error">{formState.name.error}</div>
26 )}
27 </div>
28 <div className="input_wrapper">
29 <label htmlFor="email">Email:</label>
30 <input
31 type="email"
32 name="email"
33 id="email"
34 value={formState.email.value}
35 onChange={e => {
36 onInputChange("email", e.target.value, dispatch, formState)
37 }}
38 onBlur={e => {
39 onFocusOut("email", e.target.value, dispatch, formState)
40 }}
41 />
42 {formState.email.touched && formState.email.hasError && (
43 <div className="error">{formState.email.error}</div>
44 )}
45 </div>
46 <div className="input_wrapper">
47 <label htmlFor="password">Password:</label>
48 <input
49 type="password"
50 name="password"
51 id="password"
52 value={formState.password.value}
53 onChange={e => {
54 onInputChange("password", e.target.value, dispatch, formState)
55 }}
56 onBlur={e => {
57 onFocusOut("password", e.target.value, dispatch, formState)
58 }}
59 />
60 {formState.password.touched && formState.password.hasError && (
61 <div className="error">{formState.password.error}</div>
62 )}
63 </div>
64 <div className="input_wrapper">
65 <label htmlFor="mobile">Mobile:</label>
66 <input
67 type="text"
68 name="mobile"
69 id="mobile"
70 value={formState.mobile.value}
71 onChange={e => {
72 onInputChange("mobile", e.target.value, dispatch, formState)
73 }}
74 onBlur={e => {
75 onFocusOut("mobile", e.target.value, dispatch, formState)
76 }}
77 />
78 {formState.mobile.touched && formState.mobile.hasError && (
79 <div className="error">{formState.mobile.error}</div>
80 )}
81 </div>
82 <div className="input_wrapper">
83 <label className="toc">
84 <input
85 type="checkbox"
86 name="terms"
87 checked={formState.terms.value}
88 onChange={e => {
89 onFocusOut("terms", e.target.checked, dispatch, formState)
90 }}
91 />
92 Accept terms and conditions
93 </label>
94 {formState.terms.touched && formState.terms.hasError && (
95 <div className="error">{formState.terms.error}</div>
96 )}
97 </div>
98 <div className="input_wrapper">
99 <input className="submit_btn" type="submit" value="Sign Up" />
100 </div>
101 </form>
102 </div>
103 )
104}
105
106export default App

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

App.js
1import React, { useReducer, useState } from "react"
2import "./App.css"
3import {
4 UPDATE_FORM,
5 onInputChange,
6 onFocusOut,
7 validateInput,
8} from "./lib/formUtils"
9
10//...
11
12function App() {
13 const [formState, dispatch] = useReducer(formsReducer, initialState)
14
15 const [showError, setShowError] = useState(false)
16
17 const formSubmitHandler = e => {
18 e.preventDefault() //prevents the form from submitting
19
20 let isFormValid = true
21
22 for (const name in formState) {
23 const item = formState[name]
24 const { value } = item
25 const { hasError, error } = validateInput(name, value)
26 if (hasError) {
27 isFormValid = false
28 }
29 if (name) {
30 dispatch({
31 type: UPDATE_FORM,
32 data: {
33 name,
34 value,
35 hasError,
36 error,
37 touched: true,
38 isFormValid,
39 },
40 })
41 }
42 }
43 if (!isFormValid) {
44 setShowError(true)
45 } else {
46 //Logic to submit the form to backend
47 }
48
49 // Hide the error message after 5 seconds
50 setTimeout(() => {
51 setShowError(false)
52 }, 5000)
53 }
54
55 return (
56 <div className="App">
57 <h1 className="title">Sign Up</h1>
58 {showError && !formState.isFormValid && (
59 <div className="form_error">Please fill all the fields correctly</div>
60 )}
61 <form onSubmit={e => formSubmitHandler(e)}>
62 <div className="input_wrapper">{/* ... */}</div>
63 </form>
64 </div>
65 )
66}
67
68export default App

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:

App.css
1/* ... */
2
3.form_error {
4 color: #721c24;
5 background-color: #f8d7da;
6 border-color: #f5c6cb;
7 padding: 0.5rem 1.25rem;
8 border: 1px solid transparent;
9 border-radius: 0.25rem;
10 margin: 1rem 0;
11}

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:

1yarn add webpack-bundle-analyzer -D

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

analyze.js
1// script to enable webpack-bundle-analyzer
2process.env.NODE_ENV = "production"
3const webpack = require("webpack")
4const BundleAnalyzerPlugin =
5 require("webpack-bundle-analyzer").BundleAnalyzerPlugin
6const webpackConfigProd = require("react-scripts/config/webpack.config")(
7 "production"
8)
9
10webpackConfigProd.plugins.push(new BundleAnalyzerPlugin())
11
12// actually running compilation and waiting for plugin to start explorer
13webpack(webpackConfigProd, (err, stats) => {
14 if (err || stats.hasErrors()) {
15 console.error(err)
16 }
17})

Run the following command in the terminal:

1node analyze.js

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.

Do follow me on twitter where I post developer insights more often!

Comments

NirjharJanuary 29, 2021 at 1:02 PM
[name]: { ...state[name], value, hasError, error, touched }, What is this exactly doing?
Abhishek EHFebruary 17, 2021 at 2:56 PM
It sets that particular piece of state. For example, if the name has a value of "email", then it sets the value, hasError, error, touched fields for the "email" and retains the value of other fields (if any).
TOMJuly 11, 2021 at 12:37 PM
Thank you! Best tutorial I have seen Love it
© 2024 CodingDeft.Com