Writing forms is probably one of the most time-consuming tasks in front-end development. There are several libraries (like react-hook-form) that can help reduce some of the coding required, but I have a habit of not trusting packages.
They say "don't reinvent the wheel," but sometimes the wheel is not up to your standards or does not support your specific use-case. When it comes to form validation, it's important that you 1.) assert proper data types and constraints, and 2.) not detract from the user experience in doing so.
That being said, this tutorial will focus on how you can create your own reusable universal form controller to use across your app. The code in this tutorial is based on an example project written in Next.js / TypeScript. You can find a link to the demo site and repo at the bottom of this article.
Dependencies
This tutorial will use the following dependencies:
Package Name | Purpose |
---|---|
yup | Dead simple Object schema validation |
yup-password | Password plugin for yup |
react-scroll | For animating scrolling to erroneous elements |
react-input-mask | Masking library for input fields |
tailwindcss | A utility-first CSS framework for rapidly building custom user interfaces. |
To install corresponding types:
npm i -D @types/react-input-mask @types/uuid \
@types/node @types/react @types/react-dom @types/react-scroll
The Code
The following code defines a custom hook called useForm
which manages form state, validation, and submission. It takes in several props including a FormComponent
, initialFormData
, schema
, handleSubmit
, and isLoading
. It sets up state variables for formData
and errors
and handles input change events for the form fields. It also validates the form using the provided Yup schema and provides functions to get form data, reset the form, and validate the form. Finally, it returns a render function that renders the FormComponent
with the necessary props, including any form component props passed in.
/**
* useForm is a custom hook to manage form state, validation, and submission.
* @author Jay Simons
*
*/
import React, { useState, ChangeEvent, FormEvent } from 'react';
import { scroller } from 'react-scroll';
import * as Yup from 'yup';
type FormProps<T> = {
FormComponent: React.FC<any>;
I_FormComponentProps?: I_FormComponentProps<T>;
initialFormData: T;
schema: Yup.Schema;
handleSubmit: (e: FormEvent) => Promise<void>;
isLoading: boolean;
};
export type I_FormComponentProps<T> = {
formData: T;
handleInputChange: (event: ChangeEvent<HTMLInputElement>) => void;
handleCheckboxChange: (event: ChangeEvent<HTMLInputElement>) => void;
handleSelectChange: (event: ChangeEvent<HTMLSelectElement>) => void;
handleMultiSelectChange: (event: ChangeEvent<HTMLSelectElement>) => void;
errors: { [key: string]: string };
handleSubmit: (e: FormEvent) => Promise<void>;
};
export default function useForm<T>(props: FormProps<T>) {
const { FormComponent, I_FormComponentProps, initialFormData, schema, handleSubmit, isLoading } = props;
// Initialize errors object with empty string values for each form field
const initialErrors: { [key: string]: string } = {};
for (const k in initialFormData) {
initialErrors[k] = '';
}
// Set up state variables for formData and errors
const [formData, setFormData] = useState<T>(initialFormData);
const [errors, setErrors] = useState<{ [key: string]: string }>(initialErrors);
// Handle input change events for the form fields
function handleInputChange(event: ChangeEvent<HTMLInputElement>) {
const { name, value } = event.target;
setFormData(prevState => ({ ...prevState, [name]: value }));
}
// Handle checkbox change events for the form fields
function handleCheckboxChange(event: ChangeEvent<HTMLInputElement>) {
const { name, checked } = event.target;
setFormData(prevState => ({ ...prevState, [name]: checked }));
}
// Handle select change events for the form fields
function handleSelectChange(event: ChangeEvent<HTMLSelectElement>) {
const { name, value } = event.target;
console.log(name, value);
setFormData(prevState => ({ ...prevState, [name]: value }));
}
// Handle multi-select change events for the form fields
function handleMultiSelectChange(event: React.ChangeEvent<HTMLSelectElement>) {
const { name, options } = event.target;
const selectedValues = Array.from(options) // Convert HTMLOptionsCollection to array
.filter(option => option.selected) // Filter to only selected options
.map(option => option.value); // Map to their values
setFormData(prevState => {
const formKey = name as keyof typeof prevState;
const existingValues = prevState[formKey] as string[];
// Determine the new set of values by adding or removing the selected option
const newValues = selectedValues.reduce(
(currentValues, value) => {
// Check if the value is already in the existingValues
const index = currentValues.indexOf(value);
if (index > -1) {
// If the value is already there, remove it (toggle off)
currentValues.splice(index, 1);
} else {
// If the value is not there, add it (toggle on)
currentValues.push(value);
}
return currentValues;
},
[...existingValues],
); // Start with a copy of existing values
return {
...prevState,
[formKey]: newValues,
};
});
}
// Validate the form using the Yup schema
async function validateForm(): Promise<boolean> {
// Reset errors object
setErrors(initialErrors);
try {
await schema.validate(formData, { abortEarly: false });
return true;
} catch (err) {
if (err instanceof Yup.ValidationError) {
// Retrieve the first validation error and its associated form field
let errField = err?.inner[0]?.path || ('' as string);
const errMess = err.errors[0];
// If the error field is an array element, extract the array name
const arrMatch = errField.match(/(\w+)\[(\d+)\]/);
if (arrMatch) {
errField = arrMatch[1];
}
// Scroll to the form field and display the validation error message
scroller.scrollTo(errField, {
duration: 700,
delay: 100,
smooth: true,
offset: -50,
});
// Log the error to the console and update the errors object
console.error(errField, errMess);
setErrors(oldData => ({
...oldData,
[errField]: errMess,
}));
}
return false;
}
}
function getFormData(): T {
return formData;
}
function resetForm() {
setFormData(initialFormData);
setErrors(initialErrors);
}
const assignProps: I_FormComponentProps<T> = {
formData,
handleInputChange,
handleCheckboxChange,
handleSelectChange,
handleMultiSelectChange,
errors,
handleSubmit,
};
function render() {
return (
<div className={`w-full relative ${isLoading ? 'opacity-60' : ''}`}>
{isLoading ? (
/* Cover the screen to prevent actions while loading */
<div className="absolute top-0 right-0 bottom-0 left-0 z-10 bg-black/20"></div>
) : (
<></>
)}
<FormComponent {...assignProps} {...I_FormComponentProps} />
</div>
);
}
return {
getFormData,
setFormData,
render,
resetForm,
validateForm,
};
}
Usage
Now to put all our hard work into action! Here is an example form component:
import React, { FormEvent, ChangeEvent } from 'react';
import { Element } from 'react-scroll';
import InputMask from 'react-input-mask';
import Switch from './UI/Switch';
import { I_FormData, E_FavTeams } from '@/interfaces/FormData';
import { I_FormComponentProps } from '@/hooks/useForm';
interface I_TheFormProps extends I_FormComponentProps<I_FormData> {}
export default function TheForm({
formData,
handleInputChange,
handleCheckboxChange,
handleMultiSelectChange,
handleSubmit,
errors,
}: I_TheFormProps) {
const handlePhoneChange = (event: ChangeEvent<HTMLInputElement>) => {
const { value } = event.target;
const formattedValue = value.replace(/[^\d]/g, '').substring(0, 10);
handleInputChange(event);
event.target.value = formattedValue;
};
return (
<form className="flex flex-col gap-4" onSubmit={handleSubmit}>
<div className="grid grid-cols-1 md:grid-cols-2 gap-8 md:gap-4">
<Element name="fullName">
<input
className="input-base"
type="text"
name="fullName"
onChange={handleInputChange}
placeholder="Full Name"
value={formData.fullName}
/>
{errors.fullName && <p className="text-red-500">{errors.fullName}</p>}
</Element>
<Element name="email">
<input
className="input-base"
type="email"
name="email"
onChange={handleInputChange}
placeholder="Email Address"
value={formData.email}
/>
{errors.email && <p className="text-red-500">{errors.email}</p>}
</Element>
<Element name="phone">
<InputMask
mask="(999) 999-9999"
className="input-base"
type="tel"
name="phone"
onChange={handlePhoneChange}
placeholder="Phone Number"
value={formData.phone}
/>
{errors.phone && <p className="text-red-500">{errors.phone}</p>}
</Element>
<Element name="password">
<input
className="input-base"
type="password"
name="password"
onChange={handleInputChange}
placeholder="Password"
value={formData.password}
/>
{errors.password && <p className="text-red-500">{errors.password}</p>}
</Element>
<Element name="favTeams" className="col-span-2">
<label htmlFor="fav-teams" className="">
Favorite Teams (select 1 to 3)
</label>
<select
id="fav-teams"
className="input-base"
name="favTeams"
onChange={handleMultiSelectChange}
multiple
value={formData.favTeams}
>
{Object.keys(E_FavTeams).map(team => {
const teamKey = team as keyof typeof E_FavTeams;
return (
<option key={team} value={team}>
{E_FavTeams[teamKey]}
</option>
);
})}
</select>
{errors.favTeams && <p className="text-red-500">{errors.favTeams}</p>}
</Element>
<Element name="acceptTerms">
<Switch
name="acceptTerms"
onChange={handleCheckboxChange}
checked={formData.acceptTerms}
label="Accept Terms and Conditions"
/>
{errors.acceptTerms && <p className="text-red-500">{errors.acceptTerms}</p>}
</Element>
</div>
</form>
);
}
We receive our formData
state and handlers from the HOC, which implements useForm
. Note that each input element is wrapped in the <Element>
with name=
as a prop. This allows our validation function to use react-scroll
to smoothly scroll to the erroneous input. Also note that the phone input is using InputMask
to assert the desired format of our phone number. These are not necessary for this tutorial, but I thought I'd throw them in as a bonus. 🙂
Next is our HOC that puts it all together:
import { useState, FormEvent, ChangeEventHandler } from 'react';
import Link from 'next/link';
import TheForm from '@/components/TheForm';
import Switch from '@/components/UI/Switch';
import formSchema from '@/schema/formSchema';
import useForm from '@/hooks/useForm';
import { I_FormData } from '@/interfaces/FormData';
const initialFormData: I_FormData = {
fullName: '',
email: '',
phone: '',
password: '',
acceptTerms: false,
favTeams: [],
};
export default function Home() {
const [isLoading, setIsLoading] = useState<boolean>(false);
const [disableClientValidation, setDisableClientValidation] = useState<boolean>(false);
const [serverResponse, setServerResponse] = useState<string | null>(null);
const [responseCode, setResponseCode] = useState<number>(0);
const handleSubmit = async (e: FormEvent) => {
e.preventDefault();
const formData = getFormData();
// Validate form
if (!disableClientValidation) {
if (!(await validateForm())) return;
}
setIsLoading(true);
const result = await fetch('/api/form', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(formData),
});
// Get response code
const responseCode = result.status;
setResponseCode(responseCode);
// Set response to state
const serverResponse = await result.json();
setServerResponse(serverResponse);
setIsLoading(false);
};
const { render, getFormData, validateForm, resetForm } = useForm<I_FormData>({
FormComponent: TheForm,
initialFormData: initialFormData,
schema: formSchema,
handleSubmit: handleSubmit,
isLoading: isLoading,
});
const handleDisableClientValidation = () => {
setDisableClientValidation(old => !old);
};
return (
<div className="px-4 flex flex-col min-h-screen w-full bg-gradient-to-b from-slate-300 to-slate-200">
<div className="flex flex-col gap-6 md:w-[800px] mx-auto py-20">
<h1 className="text-3xl font-bold text-center">React Hook Form Example</h1>
<p>
This form uses the Yup library for validation and the back-end is handled by Next.js's new
Edge runtime.
</p>
{serverResponse ? (
<div className="flex flex-col gap-8">
<h2 className="text-xl font-bold text-center">Server Response</h2>
<pre
className={`${
responseCode !== 200 ? 'text-red-500' : 'text-green-500'
} bg-[#1e1e1e] p-4 rounded-md`}
>
{JSON.stringify(serverResponse, null, 2)}
</pre>
<button
type="button"
className="px-4 py-2 bg-green-500 text-white rounded-md w-fit"
onClick={() => setServerResponse(null)}
>
Try Again
</button>
</div>
) : (
<>
{render()}
<div className="flex gap-6 flex-wrap">
<button
type="button"
className="px-4 py-2 bg-blue-500 text-white rounded-md"
onClick={handleSubmit}
>
Submit
</button>
<button
type="button"
className="px-4 py-2 bg-green-500 text-white rounded-md"
onClick={resetForm}
>
Reset
</button>
<Switch
label="Disable Client Validation"
onChange={handleDisableClientValidation}
checked={disableClientValidation}
/>
</div>
</>
)}
</div>
<footer className="mt-auto mb-6">
<p className="text-center text-sm font-mono">
Created by{' '}
<Link className="text-sky-600" href="https://designly.biz" target="_blank">
Designly 😀
</Link>
</p>
</footer>
</div>
);
}
As you can see, our handleSubmit
function pulls the form data from the state managed by useForm
. We then call the validateForm()
function and we abort if validation doesn't pass.
Note: the disableClientValidation
is only there for demonstration purposes.
Back-End Code
Here's an example of how you can use the same Yup schema to validate on the back-end. I'm using the super-fast Edge runtime in this example. You should consider using Edge for most back-end handlers unless you absolutely can't live without Node.
Note: you can use the toggle on the demo page to bypass front-end validation to test this.
import formSchema from "@/schema/formSchema";
import * as Yup from "yup";
// Use Next.js edge runtime
export const config = {
runtime: 'edge',
}
/**
* Form handler with Yup schema validation
*
* @author Jay Simons
*/
export default async function handler(request: Request) {
try {
const formData = await request.json();
// Vaidate form data
try {
await formSchema.validate(formData, { abortEarly: false });
} catch (err) {
if (err instanceof Yup.ValidationError) {
// Retrieve the first validation error and its associated form field
const errField = err?.inner[0]?.path || '' as string;
const errMess = err.errors[0];
throw new Error(JSON.stringify({ errField, errMess }));
}
}
return new Response(JSON.stringify(formData), {
headers: {
'Content-Type': 'application/json',
}
});
} catch (err) {
if (err instanceof Error) {
return new Response(err.message, { status: 500 });
}
}
}
Links
Thank you for taking the time to read my article and I hope you found it useful (or at the very least, mildly entertaining). For more great information about web dev, systems administration and cloud computing, please read the Designly Blog. Also, please leave your comments! I love to hear thoughts from my readers.
I use Hostinger to host my clients' websites. You can get a business account that can host 100 websites at a price of $3.99/mo, which you can lock in for up to 48 months! It's the best deal in town. Services include PHP hosting (with extensions), MySQL, Wordpress and Email services.
Looking for a web developer? I'm available for hire! To inquire, please fill out a contact form.