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:
npx create-react-app react-form-validation
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
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;
}
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
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
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,
},
})
}
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
}
}
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>
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 }
}
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 },
})
}
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
//...
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 },
})
}
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
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;
}
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
:
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 }
}
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
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
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
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;
}
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:
yarn add webpack-bundle-analyzer -D
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)
}
})
Run the following command in the terminal:
node 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.
Top comments (0)