A minimal React useForm hook
A deep dive into building a minimal form handling solution in React that tackles async validation, race conditions, and type safety.
Lately I’ve been coding a lot of forms. Form handling in React can be complex, especially when dealing with asynchronous validation, asynchronous submission, and type safety, precisely the features the in-house solution my team is using lacks of. So, I decided to create a minimal solution that would address these pain points.
Real world usage
We are going to use a form like this as the driving example:
Our useForm
hook must:
- Be type safe: We want full type-safety and autocompletion.
- Support asynchronous validation: We want to allow validation that might perform API calls, so it must support asynchronous validations with proper cancellation of outdated requests
- Communicate its state:
- submitting/valid states: Used to enable/disable form controls as corresponding
- focused/valid inputs state: Used to control which message (error or warning) to show
- Async submission: Usually submission involves API calls, so this must be supported
- Developer Experience: Simple API that doesn’t compromise on functionality nor creates a bloated chunk of code.
The console output shows the sequence of events and how our form handler manages them.
The interface
Our useForm
will have the following signature:
declare function useForm<T extends Record<string, unknown>>(options: FormOptions<T>): Form<T>;
The FormOptions<T>
interface shows our focus on async operations. Notice the validate
function receives an AbortSignal
- this is crucial for handling race conditions in async validation.
// Maps input names to their corresponding error if they are not validexport type FormErrors<T> = { [key in keyof T]?: string;};
export interface FormOptions<T> { // Initial values is used to infer the schema of the form initialValues: T;
// Used to determine if the form is "dirty" isEqual?: (a: T, b: T) => boolean;
// Validation can be sync or async. // Also, the name of the field being validated may be passed as an option, // in that case, we can return a tuple to indicate we wish to merge that // input error with the form errors of other inputs. This allows for // performing single-field validations if needed (e.g.: improve performance) validate?: ( values: T, options: { signal: AbortSignal; name?: keyof T } ) => | FormErrors<T> | [FormErrors<T>] | Promise<FormErrors<T> | [FormErrors<T>]>;
// The delay to wait before validating. // This is used to debounce the onChange event on inputs. validationDelay?: number;
// Whenever an uncontrolled error occurs when calling `validate` or // submitting, we wish to call this callback so the parent component // can handle it. onError?: (error: unknown) => void;}
And our Form<T>
model is defined as:
// Maps input names to true/false whenever they have been "touched"export type Touched<T> = { [key in keyof T]?: boolean;};
// Form submit handlers must be asynchronousexport type SubmitHandler<T> = (values: T) => Promise<void>;
export interface Form<T> { // Input state values: T; touched: Touched<T>; errors: FormErrors<T>;
// Indicates which input is focused, if any focused?: keyof T;
// Form state isValid: boolean; isDirty: boolean; submitted: boolean; isSubmitting: boolean; handleChange: (event: React.ChangeEvent<HTMLInputElement>) => void; handleBlur: (event: React.ChangeEvent<HTMLInputElement>) => void; handleFocus: (event: React.ChangeEvent<HTMLInputElement>) => void; handleSubmit: ( fn: SubmitHandler<T> ) => (event: React.FormEvent<HTMLFormElement>) => void; reset: () => void;
// Helper method to avoid repeating the same props for every input getInputProps: (name: keyof T) => { onChange: (event: React.ChangeEvent<HTMLInputElement>) => void; onBlur: (event: React.ChangeEvent<HTMLInputElement>) => void; onFocus: (event: React.ChangeEvent<HTMLInputElement>) => void; value: T[keyof T]; name: keyof T; };}
Implenting our example
Using the above interface we can easily implement our example roughly as this:
import { useEffect } from "react";import { useForm, type FormErrors } from "./useForm";
const initialValues = { name: "", surname: "",};
type Schema = typeof initialValues;
async function submitHandler(values: Schema) { // ...}
async function validate({ name, surname,}: Schema): Promise<FormErrors<Schema>> { const errors: FormErrors<Schema> = {}; // ... return errors;}
export function ExampleForm(): JSX.Element { const form = useForm({ initialValues, validate, });
const nameError = form.focused === "name" ? (form.errors.name ? "Enter your name capitalized" : "") : (form.touched.name ? form.errors.name : "");
const lastnameError = form.focused === "surname" ? (form.errors.surname ? "Must be 'Doe'" || "") : (form.touched.surname ? form.errors.surname || "");
const submitDisabled = form.isSubmitting || !form.isValid;
return ( <form onSubmit={form.handleSubmit(submitHandler)}> <div> <input disabled={form.isSubmitting} type="text" placeholder="Name" {...form.getInputProps("name")} /> <p>{nameError}</p> </div> <div> <input disabled={form.isSubmitting} type="text" placeholder="Surname" {...form.getInputProps("surname")} /> <p>{lastnameError}</p> </div> <button disabled={submitDisabled} type="submit" > Submit </button> </form> );}
The implementation
Validation
The core part is handling async validation correctly. Here’s how we manage it:
const validate = async (newValues: T, name?: keyof T): Promise<void> => { const controller = new AbortController();
// Abort ongoing validations validationController.current?.abort(); validationController.current = controller;
try { const validationErrors = await validateFn?.(newValues, { signal: controller.signal, name, });
if (!controller.signal.aborted && validationErrors !== undefined) { setErrors((prevErrors) => // Check if we wish partial or total errors update Array.isArray(validationErrors) ? { ...prevErrors, ...validationErrors[0] } : { ...validationErrors } ); } } catch (error) { if (!controller.signal.aborted) { onError?.(error); } }};
To prevent unnecessary API calls, we debounce the validation:
const validateDebounced = (newValues: T, name?: keyof T) => { clearTimeout(validationTimeoutHandler.current); validationTimeoutHandler.current = setTimeout(() => { validate(newValues, name); }, validationDelay);};
This setup ensures that:
- Only the most recent validation result is applied
- Unnecessary API calls are cancelled
- We don’t waste resources validating intermediate states
Async submission
Form submission often involves API calls, and handling the submission state correctly is crucial for UX. Our handler takes care of this automatically:
const handleSubmit: Form<T>["handleSubmit"] = (submitFn) => async (event) => { event.preventDefault();
const touchedAll = Object.keys(values).reduce( (acc, key) => ({ ...acc, [key]: true }), {} ); setTouched(touchedAll); setSubmitHandler(() => submitFn); setSubmitted(true);};// ...useEffect(() => { if (submitHandler !== undefined) { // Only submit when form is valid const task = isValid ? submitHandler(values) : Promise.resolve(); task.finally(() => { // This avoids React's warning 'State update on an unmounted component' if (!unmounted.current) { setSubmitHandler(undefined); } }); }}, [submitHandler, values, isValid]);
Entire implementation
Here’s how the entire implementation looks like:
54 collapsed lines
import { useEffect, useRef, useState } from "react";
export type Touched<T> = { [key in keyof T]?: boolean;};
export type SubmitHandler<T> = (values: T) => Promise<void>;
export type FormErrors<T> = { [key in keyof T]?: string;};
export interface FormOptions<T> { initialValues: T; isEqual?: (a: T, b: T) => boolean; validate?: ( values: T, options: { signal: AbortSignal; name?: keyof T } ) => | FormErrors<T> | [FormErrors<T>] | Promise<FormErrors<T> | [FormErrors<T>]>; validationDelay?: number; onError?: (error: unknown) => void;}
export interface Form<T> { values: T; touched: Touched<T>; errors: FormErrors<T>; focused?: keyof T; isValid: boolean; isDirty: boolean; submitted: boolean; isSubmitting: boolean; handleChange: (event: React.ChangeEvent<HTMLInputElement>) => void; handleBlur: (event: React.ChangeEvent<HTMLInputElement>) => void; handleFocus: (event: React.ChangeEvent<HTMLInputElement>) => void; handleSubmit: ( fn: SubmitHandler<T> ) => (event: React.FormEvent<HTMLFormElement>) => void; reset: () => void; getInputProps: (name: keyof T) => { onChange: (event: React.ChangeEvent<HTMLInputElement>) => void; onBlur: (event: React.ChangeEvent<HTMLInputElement>) => void; onFocus: (event: React.ChangeEvent<HTMLInputElement>) => void; value: T[keyof T]; name: keyof T; };}
export const defaultIsEqual = <T,>(a: T, b: T): boolean => a === b;export const defaultValidationDelay = 200;
export function useForm<T extends Record<string, unknown>>({ initialValues, validate: validateFn, isEqual = defaultIsEqual, validationDelay = defaultValidationDelay, onError,}: FormOptions<T>): Form<T> { const [values, setValues] = useState(initialValues); const [errors, setErrors] = useState<FormErrors<T>>({}); const [touched, setTouched] = useState<Touched<T>>({}); const [submitted, setSubmitted] = useState(false); const [focused, setFocused] = useState<keyof T>(); const [submitHandler, setSubmitHandler] = useState<SubmitHandler<T>>();
const validationTimeoutHandler = useRef<NodeJS.Timeout | undefined>(); const validationController = useRef<AbortController | undefined>(); const unmounted = useRef(false);
const isValid = Object.keys(errors).length === 0; const isDirty = !isEqual(values, initialValues); const isSubmitting = submitHandler !== undefined;
const validate = async (newValues: T, name?: keyof T): Promise<void> => {23 collapsed lines
const controller = new AbortController();
validationController.current?.abort(); validationController.current = controller;
try { const validationErrors = await validateFn?.(newValues, { signal: controller.signal, name, });
if (!controller.signal.aborted && validationErrors !== undefined) { setErrors((prevErrors) => Array.isArray(validationErrors) ? { ...prevErrors, ...validationErrors[0] } : { ...validationErrors } ); } } catch (error) { if (!controller.signal.aborted) { onError?.(error); } } };
const validateDebounced = (newValues: T, name?: keyof T) => {4 collapsed lines
clearTimeout(validationTimeoutHandler.current); validationTimeoutHandler.current = setTimeout(() => { validate(newValues, name); }, validationDelay); };
const handleChange: Form<T>["handleChange"] = (event) => {4 collapsed lines
const { name, value } = event.target; const newValues = { ...values, [name]: value }; setValues(newValues); validateDebounced(newValues, name as keyof T); };
const handleBlur: Form<T>["handleBlur"] = (event) => {4 collapsed lines
const { name } = event.target;
setTouched((prev) => ({ ...prev, [name]: true })); setFocused(undefined); };
const handleFocus: Form<T>["handleFocus"] = (event) => {3 collapsed lines
const { name } = event.target;
setFocused(name as keyof T); };
const handleSubmit: Form<T>["handleSubmit"] = (submitFn) => async (event) => {9 collapsed lines
event.preventDefault();
const touchedAll = Object.keys(values).reduce( (acc, key) => ({ ...acc, [key]: true }), {} ); setTouched(touchedAll); setSubmitHandler(() => submitFn); setSubmitted(true); };
const reset = () => {5 collapsed lines
setValues(initialValues); setFocused(undefined); setErrors({}); setTouched({}); setSubmitted(false); };
20 collapsed lines
useEffect(() => { validate(values);
return () => { unmounted.current = true; validationController.current?.abort(); clearTimeout(validationTimeoutHandler.current); }; }, []);
useEffect(() => { if (submitHandler !== undefined) { const task = isValid ? submitHandler(values) : Promise.resolve(); task.finally(() => { if (!unmounted.current) { setSubmitHandler(undefined); } }); } }, [submitHandler, values, isValid]);
return { values, errors, touched, focused, submitted, isValid, isDirty, isSubmitting, handleChange, handleBlur, handleFocus, handleSubmit, reset, getInputProps: (name) => ({ onChange: handleChange, onBlur: handleBlur, onFocus: handleFocus, value: values[name], name, }), };}
Conclusion
Building this form handler have solved my specific needs around async operations and type safety. While there are many form libraries available, sometimes building a custom solution that perfectly fits your needs is the right choice.