A minimal React useForm hook

Nov 04, 2024
react
snippet
typescript

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 valid
export 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 asynchronous
export 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:

useForm.ts
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.