Enhancing React Forms with Asynchronous Validation: A Deep Dive into useForm Hook

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:

  1. initialState – An object representing the initial state of the form.
  2. validateField – A function responsible for validating a field. This function can perform both synchronous and asynchronous validations.
  3. 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.

Leave a Reply

Your email address will not be published. Required fields are marked *