Reactive forms with React hooks and typescript
You've probably been writing react apps that require you to create forms and collect user inputs, validate them and submit. You may have used libraries like formik to build even more complex forms easily. In this article, we'll be learning how to use react hooks and typescript to build our own reactive forms. This will teach you the power of react hooks and creating your own custom hooks.
For easy follow up to this article, you'll be expected to be able to
- Build basic apps with react and typescript
- Use basic react inbuilt hooks like useEffect and useState
Enough of the talking, let's get coding. We'll be building a reactive form in react with custom react hooks and and apply this to a registration form. Our custom hook will return validation results that we can use in our form to be able display error messages or take actions. The following below will show you how our reactive form behaves to user interaction
Before we proceed to the implementation, let's see how we'll be using our form validator library in a form component for validation
const {
inputFields,
inputFieldsErrorMsgs,
handleChange,
formRef,
isValid,
}: useFormResult = useForm({
fullName: ['', [Validators.validateRequired()]],
company: [''],
username: [
'',
[Validators.validateRequired()],
[asyncValidateUsername('User with username already exists')],
],
email: ['', [Validators.validateRequired(), Validators.validateEmail()]],
password: ['', [Validators.validateRequired()]],
});
Our custom hook returns the following that can be used within the form reactively:
- inputFields: An object of the input fields and their status
- inputFieldsErrorMsgs: An object of the input fields and the error messages if any
- handleChange: A function that'll be added to the change handler of each input field
- formRef: Reference to the form element
- isValid: boolean that indicates if the form is valid or not
It also takes in as input the different form fields, the default values and the synchronous and asynchronous validator functions
We'll create a Validator.ts file containing a validator class with default validators including validateRequired
, validateEmail
, validateUsername
Our validator functions will be higher order functions taking an error message as argument and returning a function of type ValidatorFn
that takes in the form input value as argument and returns a ValidationResult
type.
Our Validator.ts file will look like this with our default validators
export interface ValidationResult {
error: boolean;
msg: string;
}
export interface ValidationFn {
(msg: string): ValidationResult;
}
export interface AsyncValidatorFn {
(msg: string): Promise<ValidationResult>
}
export class Validators {
static validateRequired(msg = "This field is required"): ValidationFn {
return (value: string): ValidationResult => {
return {
error: !value,
msg
}
};
}
static validateEmail(msg = "Email is not valid"): ValidationFn {
return (value: string): ValidationResult => {
const regex = /^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$/;
return {
error: !regex.test(value),
msg,
};
};
}
static validateUsername(msg = "Username is not valid"): ValidationFn {
return (value: string): ValidationResult => {
const regex = /^[-a-zA-Z0-9_]{2,30}$/;
return {
error: !regex.test(value),
msg,
};
};
}
}
Also notice we have a AsyncValidatorFn
type in case we need to implement an asynchronous validator. Users of validator functionality can implement custom validators of type ValidatorFn
and AsyncValidatorFn
We'll then create a custom hook called useForm
in a file useForm.ts. Let's declare a type for our hook as shown
export interface useFormResult {
inputFields: Record<string, Interaction>;
setInputFields: (newState: Record<string, Interaction>) => void;
inputFieldsErrorMsgs: Record<string, string[]>;
setInputFieldsErrorMsgs: (newState: Record<string, string[]>) => void;
handleChange: (e: FormEvent<HTMLInputElement>) => void;
formRef: RefObject<HTMLFormElement>;
isValid: boolean;
}
export type useFormType = (
validations: Record<string, [string, ValidationFn[]?, AsyncValidatorFn[]?]>
) => useFormResult;
Next we'll initialize the form input elements, error messages and reference to the form
const initializeInputFields = (
validations: Record<string, [string, ValidationFn[]?, AsyncValidatorFn[]?]>
) => {
const fields: Record<string, Interaction> = {};
const fieldsErrorMsgs: Record<string, string[]> = {};
Object.keys(validations).forEach((key) => {
fieldsErrorMsgs[key] = [];
fields[key] = {
dirty: false,
touched: false,
value: validations[key][0],
valid: !(!!validations[key][1]?.length || !!validations[key][2]?.length),
};
});
return { fields, fieldsErrorMsgs };
};
const useForm: useFormType = (
validations: Record<string, [string, ValidationFn[]?, AsyncValidatorFn[]?]>
) => {
const { fields, fieldsErrorMsgs } = initializeInputFields(validations);
const [inputFields, setInputFields] =
useState<Record<string, Interaction>>(fields);
const [inputFieldsErrorMsgs, setInputFieldsErrorMsgs] =
useState<Record<string, string[]>>(fieldsErrorMsgs);
const [isValid, setIsValid] = useState<boolean>(false);
const formRef: RefObject<HTMLFormElement> = useRef<HTMLFormElement>(null);
}
We'll use the useEffect
hook to add focus event listeners to the input elements. This is to ensure we know when the input element has been touched.
useEffect(() => {
const formEl = formRef.current;
Object.keys(validations).forEach((key) => {
const inputEl = formEl?.querySelector(
`[name="${key}"]`
) as HTMLInputElement;
inputEl?.addEventListener('focus', async () => {
const validationResult = validateField(key, inputEl.value);
updateFieldsAfterValidation(key, validationResult, 'focus');
const asyncValidationResult = await asyncValidateField(
key,
inputEl.value
);
if (asyncValidationResult.length) {
updateFieldsAfterValidation(
key,
[...asyncValidationResult, ...validationResult],
'focus'
);
}
return;
});
});
const updateFieldsAfterValidation = (
key: string,
validationResult: string[],
event: 'focus' | 'change'
) => {
validationResult = Array.from(new Set(validationResult));
setInputFieldsErrorMsgs((_inputFieldsErrorMsgs) => ({
..._inputFieldsErrorMsgs,
[key]: validationResult,
}));
setInputFields((_inputFields) => {
const validArr = Object.entries(_inputFields).map(([_key, value]) => {
return key === _key ? validationResult.length === 0 : value.valid;
});
setIsValid(!validArr.some((valid) => valid === false));
return {
..._inputFields,
[key]: {
..._inputFields[key],
touched: event === 'focus' ? true : _inputFields[key].touched,
dirty: event === 'change' ? true : _inputFields[key].dirty,
valid: validationResult.length === 0,
},
};
});
};
const validateField = (name: string, value: string): string[] => {
const fieldErrors: string[] = [];
const fieldValidations = validations[name];
if (fieldValidations.length > 1) {
fieldValidations[1]?.forEach((validate) => {
const validateErr = validate(value) as ValidationResult;
if (validateErr.error) {
fieldErrors.push(validateErr.msg);
}
});
}
return fieldErrors;
};
const asyncValidateField = async (
name: string,
value: string
): Promise<string[]> => {
const fieldErrors: string[] = [];
const fieldValidations = validations[name];
if (fieldValidations.length > 2) {
const aysncFieldValidations = fieldValidations[2]
? fieldValidations[2].map(async (validate) => validate(value))
: [];
const asyncValidationResults = await Promise.all(aysncFieldValidations);
asyncValidationResults.forEach((validateErr: ValidationResult) => {
if (validateErr.error) {
fieldErrors.push((validateErr as ValidationResult).msg);
}
});
}
return fieldErrors;
};
We use the validateField
function to call the synchronous validator functions registered for that input field and asyncValidateField
function to call asynchronous validator functions registered for that input field. An example case where we will want to apply asynchronous validation is in a case where we want to make an API call to verify if a username already exists.
Next let's create the onChange handler function
const handleChange = async (e: FormEvent<HTMLInputElement>) => {
const { name, value } = e.currentTarget;
setInputFields((_inputFields) => ({
..._inputFields,
[name]: {
..._inputFields[name],
value,
dirty: true,
},
}));
const validationResult = validateField(name, value);
updateFieldsAfterValidation(name, validationResult, 'change');
const asyncValidationResult = await asyncValidateField(name, value);
if (asyncValidationResult.length) {
updateFieldsAfterValidation(
name,
[...asyncValidationResult, ...validationResult],
'change'
);
}
};
import { FormEvent, RefObject, useEffect, useRef, useState } from 'react';
import { AsyncValidatorFn, ValidationFn, ValidationResult } from './Validators';
export interface Interaction {
touched: boolean;
dirty: boolean;
valid: boolean;
value: string;
}
export interface useFormResult {
inputFields: Record<string, Interaction>;
setInputFields: (newState: Record<string, Interaction>) => void;
inputFieldsErrorMsgs: Record<string, string[]>;
setInputFieldsErrorMsgs: (newState: Record<string, string[]>) => void;
handleChange: (e: FormEvent<HTMLInputElement>) => void;
formRef: RefObject<HTMLFormElement>;
isValid: boolean;
}
const initializeInputFields = (
validations: Record<string, [string, ValidationFn[]?, AsyncValidatorFn[]?]>
) => {
const fields: Record<string, Interaction> = {};
const fieldsErrorMsgs: Record<string, string[]> = {};
Object.keys(validations).forEach((key) => {
fieldsErrorMsgs[key] = [];
fields[key] = {
dirty: false,
touched: false,
value: validations[key][0],
valid: !(!!validations[key][1]?.length || !!validations[key][2]?.length),
};
});
return { fields, fieldsErrorMsgs };
};
export type useFormType = (
validations: Record<string, [string, ValidationFn[]?, AsyncValidatorFn[]?]>
) => useFormResult;
const useForm: useFormType = (
validations: Record<string, [string, ValidationFn[]?, AsyncValidatorFn[]?]>
) => {
const { fields, fieldsErrorMsgs } = initializeInputFields(validations);
const [inputFields, setInputFields] =
useState<Record<string, Interaction>>(fields);
const [inputFieldsErrorMsgs, setInputFieldsErrorMsgs] =
useState<Record<string, string[]>>(fieldsErrorMsgs);
const [isValid, setIsValid] = useState<boolean>(false);
const formRef: RefObject<HTMLFormElement> = useRef<HTMLFormElement>(null);
useEffect(() => {
const formEl = formRef.current;
Object.keys(validations).forEach((key) => {
const inputEl = formEl?.querySelector(
`[name="${key}"]`
) as HTMLInputElement;
inputEl?.addEventListener('focus', async () => {
const validationResult = validateField(key, inputEl.value);
updateFieldsAfterValidation(key, validationResult, 'focus');
const asyncValidationResult = await asyncValidateField(
key,
inputEl.value
);
if (asyncValidationResult.length) {
updateFieldsAfterValidation(
key,
[...asyncValidationResult, ...validationResult],
'focus'
);
}
return;
});
});
return () => {
Object.keys(validations).forEach((key) => {
formEl
?.querySelector(`[name="${key}"]`)
?.removeEventListener('focus', () => {});
});
};
}, []);
const updateFieldsAfterValidation = (
key: string,
validationResult: string[],
event: 'focus' | 'change'
) => {
validationResult = Array.from(new Set(validationResult));
setInputFieldsErrorMsgs((_inputFieldsErrorMsgs) => ({
..._inputFieldsErrorMsgs,
[key]: validationResult,
}));
setInputFields((_inputFields) => {
const validArr = Object.entries(_inputFields).map(([_key, value]) => {
return key === _key ? validationResult.length === 0 : value.valid;
});
setIsValid(!validArr.some((valid) => valid === false));
return {
..._inputFields,
[key]: {
..._inputFields[key],
touched: event === 'focus' ? true : _inputFields[key].touched,
dirty: event === 'change' ? true : _inputFields[key].dirty,
valid: validationResult.length === 0,
},
};
});
};
const validateField = (name: string, value: string): string[] => {
const fieldErrors: string[] = [];
const fieldValidations = validations[name];
if (fieldValidations.length > 1) {
fieldValidations[1]?.forEach((validate) => {
const validateErr = validate(value) as ValidationResult;
if (validateErr.error) {
fieldErrors.push(validateErr.msg);
}
});
}
return fieldErrors;
};
const asyncValidateField = async (
name: string,
value: string
): Promise<string[]> => {
const fieldErrors: string[] = [];
const fieldValidations = validations[name];
if (fieldValidations.length > 2) {
const aysncFieldValidations = fieldValidations[2]
? fieldValidations[2].map(async (validate) => validate(value))
: [];
const asyncValidationResults = await Promise.all(aysncFieldValidations);
asyncValidationResults.forEach((validateErr: ValidationResult) => {
if (validateErr.error) {
fieldErrors.push((validateErr as ValidationResult).msg);
}
});
}
return fieldErrors;
};
const handleChange = async (e: FormEvent<HTMLInputElement>) => {
const { name, value } = e.currentTarget;
setInputFields((_inputFields) => ({
..._inputFields,
[name]: {
..._inputFields[name],
value,
dirty: true,
},
}));
const validationResult = validateField(name, value);
updateFieldsAfterValidation(name, validationResult, 'change');
const asyncValidationResult = await asyncValidateField(name, value);
if (asyncValidationResult.length) {
updateFieldsAfterValidation(
name,
[...asyncValidationResult, ...validationResult],
'change'
);
}
};
return {
inputFields,
setInputFields,
inputFieldsErrorMsgs,
setInputFieldsErrorMsgs,
handleChange,
formRef,
isValid,
};
};
export default useForm;
Now let's see how we can use this to create a reactive registration form. Our registration form component should look like
import React, { FormEvent, useEffect } from 'react';
import './RegistrationForm.css'; // Import your CSS file for styling
import useForm, { useFormResult } from '../ReactiveForm/useForm';
import {
AsyncValidatorFn,
ValidationResult,
Validators,
} from '../ReactiveForm/Validators';
const existingUsers = [
'pixelpioneer',
'techtraveler',
'codecrafter',
'digitaldreamer',
'bytebuilder',
'nerdynavigator',
'geekyguru',
'circuitsage',
'binaryboss',
'techietitan',
];
const asyncValidateUsername = (msg: string): AsyncValidatorFn => {
return (value: string): Promise<ValidationResult> => {
return new Promise((resolve, reject) => {
setTimeout(
() => resolve({ error: existingUsers.includes(value), msg }),
2000
);
});
};
};
const RegistrationForm = () => {
const {
inputFields,
inputFieldsErrorMsgs,
handleChange,
formRef,
isValid,
}: useFormResult = useForm({
fullName: ['', [Validators.validateRequired()]],
company: [''],
username: [
'',
[Validators.validateRequired()],
[asyncValidateUsername('User with username already exists')],
],
email: ['', [Validators.validateRequired(), Validators.validateEmail()]],
password: ['', [Validators.validateRequired()]],
});
useEffect(() => {}, []);
const handleSubmit = (e: FormEvent<HTMLFormElement>) => {
e.preventDefault();
};
return (
<div className="registration-form">
{/* {JSON.stringify(inputFields)}
{JSON.stringify(inputFieldsErrorMsgs)}
{JSON.stringify(isValid)} */}
<h2>Register</h2>
<form onSubmit={handleSubmit} ref={formRef}>
<div className="form-group">
<input
type="text"
name="fullName"
placeholder="Full Name"
value={inputFields.fullName.value}
onChange={handleChange}
/>
{inputFields?.fullName?.touched && !inputFields?.fullName?.valid && (
<p className="error">
{inputFieldsErrorMsgs?.fullName.map((error) => (
<span key={error}>{error}</span>
))}
</p>
)}
</div>
<div className="form-group">
<input
type="text"
name="company"
placeholder="Company"
value={inputFields.company.value}
onChange={handleChange}
/>
{inputFields?.company?.touched && !inputFields?.company?.valid && (
<p className="error">
{inputFieldsErrorMsgs?.company.map((error) => (
<span key={error}>{error}</span>
))}
</p>
)}
</div>
<div className="form-group">
<input
type="email"
name="email"
placeholder="Email"
value={inputFields.email.value}
onChange={handleChange}
/>
{inputFields?.email?.touched && !inputFields?.email?.valid && (
<p className="error">
{inputFieldsErrorMsgs?.email.map((error) => (
<span key={error}>{error}</span>
))}
</p>
)}
</div>
<div className="form-group">
<input
type="text"
name="username"
placeholder="Username"
value={inputFields.username.value}
onChange={handleChange}
/>
{inputFields?.username?.touched && !inputFields?.username?.valid && (
<p className="error">
{inputFieldsErrorMsgs?.username.map((error) => (
<span key={error}>{error}</span>
))}
</p>
)}
</div>
<div className="form-group">
<input
type="password"
name="password"
placeholder="Password"
value={inputFields.password.value}
onChange={handleChange}
/>
{inputFields?.password?.touched && !inputFields?.password?.valid && (
<p className="error">
{inputFieldsErrorMsgs?.password.map((error) => (
<span key={error}>{error}</span>
))}
</p>
)}
</div>
<button type="submit">Register</button>
</form>
</div>
);
};
export default RegistrationForm;
Notice how we have added an asynchronous validator function asyncValidateUsername
to simulate an api call that checks from an array of usernames to ensure the inputted username doesn't belong to the list and displays the error accordingly.
You can find the complete project on github