As modern web applications evolve, the necessity for more dynamic and responsive forms has become apparent. Particularly, the ability to perform asynchronous validation in React forms adds a layer of depth and interactivity that can significantly enhance the user experience. In this post, we’re going to explore an advanced useForm
custom hook, focusing on its asynchronous capabilities.
The Power of Asynchronous Validation in React
Asynchronous validation is crucial when form inputs need to be validated against server-side data, such as checking the uniqueness of a username or validating a complex input that requires backend processing. React, being a powerful UI library, offers us the flexibility to implement this efficiently.
Introducing the `useForm` Custom Hook
The `useForm` hook is a custom React hook designed to manage form state, handle both synchronous and asynchronous field validations, and provide an easy way to submit forms. It abstracts away the complexity of form handling, making it easier to maintain and scale React applications. Let’s define the Typescript types and hook signature
interface FormState {
formData: { [key: string]: any };
errors: { [key: string]: string | undefined };
validated: { [key: string]: boolean };
}
interface FormAction {
type: string;
name?: string;
value?: any;
error?: string;
}
export type ValidateFieldFunctionArgs = { name: string; value: any; signal?: AbortSignal; initialState: any; requiredFields: any };
type ValidateFieldFunction = (args: ValidateFieldFunctionArgs) => Promise<string>;
type FormFields = { [key: string]: { value: string | number; isRequired?: boolean } };
export const useForm = (formFields, validateField) => {
// ...hook logic...
};
The useForm
hook takes four parameters:
initialState
– An object representing the initial state of the form.validateField
– A function responsible for validating a field. This function can perform both synchronous and asynchronous validations.requiredFields
– An object indicating which fields are required.
Asynchronous Validation
One of the key features of useForm
is its ability to handle asynchronous validation seamlessly. This is achieved using the AbortController
API, which allows us to cancel previous validation requests when a new validation starts. This ensures that our form always reflects the latest validation state. You can either use lodash debounce or your own simple debounce method for this
// Extract initial values and required fields from formFields
const initialState = Object.keys(formFields).reduce<{ [key: string]: string | number }>((acc, key) => {
acc[key] = formFields[key].value;
return acc;
}, {});
const requiredFields = Object.keys(formFields).reduce<{ [key: string]: boolean | undefined }>((acc, key) => {
acc[key] = formFields[key].isRequired;
return acc;
}, {});
// keeps track of async validations to abort any ongoing validations
const abortControllersRef = useRef<{ [key: string]: AbortController }>({});
const [state, dispatch] = useReducer(formReducer, buildInitialState(initialState));
const { errors, validated, formData } = state;
const validate = useCallback(
// debounce validation so it validation only triggers when user stops typing momentarily
debounce(async (name: string, value: string | number) => {
// Cancel the previous async validation request for this specific field
abortControllersRef.current[name]?.abort();
abortControllersRef.current[name] = new AbortController();
// let the form know a valdation is starting to disable the submit button
dispatch({ type: ACTION_START_VALIDATION, name });
const error = await validateField({ name, value, signal: abortControllersRef.current[name].signal, initialState, requiredFields });
dispatch({ type: ACTION_SET_ERROR, name, error });
}, 500),
[]
);
onChange and onBlur handlers
Next are our simple onChange
and onBlur
functions. The assumption here is we only want the async validations to accur when there’s a value so check for that when calling onBlur. Since we only want the async validations to occur when there’s a value we let the onChange handle handle async validation so we dont pass a singal object in to indicate to skip the async validations. Since async valitions are resource heavy compared to sync validations we want oto minimize their usage.
const onChange = ({ target: { id: name, value } }: any) => {
dispatch({ type: ACTION_UPDATE_FIELD, name, value });
validate(name, value);
};
const onBlur = async (name: string, value: string | number) => {
// on blur should only validate when field is empty
// since we only want to async validate when the user has typed something in not on blur
// this avoids validating twice
if (formData[name]) return;
const error = await validateField({name, value, initialState, requiredFields });
dispatch({ type: ACTION_SET_ERROR, name, error });
};
Form Validation Boolean
Finally, we want a boolean to indicate weather the form is in a valid state or not
const isFormValid = () => {
const allRequiredValidated = Object.keys(initialState)
.filter((name) => requiredFields[name]) // Filter out only required fields
.every((name) => validated[name]); // Check if all required fields are validated
const anyErrors = Object.values(errors).some(Boolean); // Check if there are any errors
return allRequiredValidated && !anyErrors;
};
// return everything
return { formData, errors, onChange, onBlur, isFormValid: isFormValid() };
Helper functions for useForm hook
Here we want to automate the buiding of the internal form state to handle validation asyncronously. We need three main components of the state: the first is the form state itself. Next we need the error states of each input. Lastly we need the current validation state for each input. This allows us to know if the current form is in a valid state or not. This is important since the form state is dependant on asyncronous behavior so we want to make sure to let the form state know the validation is pending
// builds the initial form state to keep track of the form state along with any pending validations and errors
export const buildInitialState = (initialState: { [key: string]: any }) => {
const initialErrors: { [key: string]: string } = {};
const initialValidated: { [key: string]: boolean } = {};
// initialize the errors and validated objects based on the keys in initialState
Object.keys(initialState).forEach((key) => {
initialErrors[key] = '';
initialValidated[key] = false;
});
return {
formData: { ...initialState },
errors: initialErrors,
validated: initialValidated,
};
};
Finally, we want the reducer used in the useReducer
hook to update the form state
const ACTION_START_VALIDATION = 'START_VALIDATION';
const ACTION_SET_ERROR = 'SET_ERROR';
const ACTION_UPDATE_FIELD = 'UPDATE_FIELD';
// useReducer function to keep track of the current form state
const formReducer = (state: FormState, action: FormAction): FormState => {
switch (action.type) {
case ACTION_UPDATE_FIELD:
return {
...state,
formData: { ...state.formData, [action.name as string]: action.value },
errors: { ...state.errors, [action.name as string]: '' },
};
case ACTION_SET_ERROR:
return {
...state,
errors: { ...state.errors, [action.name as string]: action.error },
validated: { ...state.validated, [action.name as string]: true },
};
// for async validation so ui knows to wait on validation
case ACTION_START_VALIDATION:
return {
...state,
validated: { ...state.validated, [action.name as string]: false },
};
default:
return state;
}
};
Example usage of useForm hook
It’s finally time to use the asyncronous form validation hook. First we define the formFields
, then the validateField
function that contains all validations including or async validation to an API call to valid the zipcode. Then we use the generated objects from useForm
to make our asyncronous form!
export async function validateField({ name, value, signal, requiredFields }: ValidateFieldFunctionArgs) {
if (requiredFields[name] && !value) return 'This field is required';
if (!signal) return '';
if (name === 'zipcode') {
const errorMessage = await validateZipcode(value, signal);
if (errorMessage) return errorMessage;
}
return '';
}
async function validateZipcode(zipcode, signal) {
try {
const response = await fetch('my-api/zipcode-validation', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ zipcode }),
signal,
});
const data = await response.json();
return data.validation;
} catch (error) {
return `Error validating zipcode: ${error}`;
}
}
const MyForm = ({ name }) => {
const formFields = {
name: { value: '', isRequired: true },
address: { value: '' },
city: { value: '' },
state: { value: '' },
zipcode: { value: '', isRequired: true },
};
const { formData, errors, onChange, onBlur, isFormValid } = useForm(formFields, validateField);
const handleSubmit = (event) => {
event.preventDefault();
if (!isInvalidForm) {
// Submit logic goes here
}
};
return (
<form onSubmit={handleSubmit}>
<div>
<label htmlFor="name">Name</label>
<input
type="text"
id="name"
value={formData.name}
onChange={onChange}
onBlur={() => onBlur('name', formData.name)}
/>
{errors.name && <span className="error">{errors.name}</span>}
</div>
<div>
<label htmlFor="address">Address</label>
<input
type="text"
id="address"
value={formData.address}
onChange={onChange}
onBlur={() => onBlur('address', formData.address)}
/>
{errors.address && <span className="error">{errors.address}</span>}
</div>
{/* Similarly add fields for city, state, and zipcode */}
<button type="submit" disabled={!isFormValid}>Submit</button>
</form>
);
};
Conclusion
The useForm
hook demonstrates the power of custom hooks in React, especially for handling complex tasks like asynchronous validation in forms. By abstracting away the form logic, it allows developers to focus more on the UI and less on state management, making their code cleaner and more maintainable.