Reactive forms with React hooks and typescript

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