Table of Contents
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.
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.
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.
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:
1import React from "react"2import "./App.css"34function 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 conditions28 </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}3738export default App
Here we have created a simple sign up form with few fields. Now to style these fields let's add some 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:
Binding the form value with the state
Now that we have the form ready, let's bind the input values with the state
1import React, { useReducer } from "react"2import "./App.css"34/**5 * The initial state of the form6 * value: stores the value of the input field7 * touched: indicates whether the user has tried to input anything in the field8 * 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 message11 * 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}2122/**23 * Reducer which will perform form state update24 */25const formsReducer = (state, action) => {26 return state27}2829function App() {30 const [formState, dispatch] = useReducer(formsReducer, initialState)3132 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 <input39 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 <input48 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 <input57 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 <input66 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 <input75 type="checkbox"76 name="terms"77 checked={formState.terms.value}78 />{" "}79 Accept terms and conditions80 </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}8990export 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.
1export const UPDATE_FORM = "UPDATE_FORM"23/**4 * Triggered every time the value of the form changes5 */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.
1//...23import { UPDATE_FORM, onInputChange } from "./lib/formUtils"45//...6const formsReducer = (state, action) => {7 switch (action.type) {8 case UPDATE_FORM:9 const { name, value, hasError, error, touched, isFormValid } = action.data10 return {11 ...state,12 // update the state of the particular field,13 // by retaining the state of other fields14 [name]: { ...state[name], value, hasError, error, touched },15 isFormValid,16 }17 default:18 return state19 }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 <input4 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 = true8 error = "Name cannot be empty"9 } else if (!/^[a-zA-Z ]+$/.test(value)) {10 hasError = true11 error = "Invalid Name. Avoid Special characters"12 } else {13 hasError = false14 error = ""15 }16 break17 default:18 break19 }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 = true45 for (const key in formState) {6 const item = formState[key]7 // Check if the current field has error8 if (key === name && hasError) {9 isFormValid = false10 break11 } else if (key !== name && item.hasError) {12 // Check if any other field has error13 isFormValid = false14 break15 }16 }1718 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
Use
console.table({"name state": formState.name});
for displaying the values of an object in tabular format
When the name is kept empty
When a valid name is entered
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 = true5 for (const key in formState) {6 const item = formState[key]7 if (key === name && hasError) {8 isFormValid = false9 break10 } else if (key !== name && item.hasError) {11 isFormValid = false12 break13 }14 }1516 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:
1//...2import { UPDATE_FORM, onInputChange, onFocusOut } from "./lib/formUtils"34//...5function App() {6 const [formState, dispatch] = useReducer(formsReducer, initialState)78 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 <input15 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}3536export 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
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:
Adding validation to other fields
Now let's add validation to other fields
Update the validateInput
function inside 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 = true8 error = "Name cannot be empty"9 } else if (!/^[a-zA-Z ]+$/.test(value)) {10 hasError = true11 error = "Invalid Name. Avoid Special characters"12 } else {13 hasError = false14 error = ""15 }16 break17 case "email":18 if (value.trim() === "") {19 hasError = true20 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 value24 )25 ) {26 hasError = true27 error = "Invalid Email"28 } else {29 hasError = false30 error = ""31 }32 break33 case "password":34 if (value.trim() === "") {35 hasError = true36 error = "Password cannot be empty"37 } else if (value.trim().length < 8) {38 hasError = true39 error = "Password must have at least 8 characters"40 } else {41 hasError = false42 error = ""43 }44 break45 case "mobile":46 if (value.trim() === "") {47 hasError = true48 error = "Mobile cannot be empty"49 } else if (!/^[0-9]{10}$/.test(value)) {50 hasError = true51 error = "Invalid Mobile Number. Use 10 digits only"52 } else {53 hasError = false54 error = ""55 }56 break57 case "terms":58 if (!value) {59 hasError = true60 error = "You must accept terms and conditions"61 } else {62 hasError = false63 error = ""64 }65 break66 default:67 break68 }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:
1//...23function App() {4 const [formState, dispatch] = useReducer(formsReducer, initialState)56 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 <input13 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 <input31 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 <input49 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 <input67 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 <input85 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 conditions93 </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}105106export default App
Now if you test the application, you will see all validations in place:
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
1import React, { useReducer, useState } from "react"2import "./App.css"3import {4 UPDATE_FORM,5 onInputChange,6 onFocusOut,7 validateInput,8} from "./lib/formUtils"910//...1112function App() {13 const [formState, dispatch] = useReducer(formsReducer, initialState)1415 const [showError, setShowError] = useState(false)1617 const formSubmitHandler = e => {18 e.preventDefault() //prevents the form from submitting1920 let isFormValid = true2122 for (const name in formState) {23 const item = formState[name]24 const { value } = item25 const { hasError, error } = validateInput(name, value)26 if (hasError) {27 isFormValid = false28 }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 backend47 }4849 // Hide the error message after 5 seconds50 setTimeout(() => {51 setShowError(false)52 }, 5000)53 }5455 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}6768export 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:
1/* ... */23.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:
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:
1// script to enable webpack-bundle-analyzer2process.env.NODE_ENV = "production"3const webpack = require("webpack")4const BundleAnalyzerPlugin =5 require("webpack-bundle-analyzer").BundleAnalyzerPlugin6const webpackConfigProd = require("react-scripts/config/webpack.config")(7 "production"8)910webpackConfigProd.plugins.push(new BundleAnalyzerPlugin())1112// actually running compilation and waiting for plugin to start explorer13webpack(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!
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