Published on

Advanced Form Development with React Hook Form

Authors

HTML forms have been an integral part of the web since its inception. At it's core, the basic functionality of a web form has not changed. However, the way we build them definitely has, and our ability to create complex web forms has advanced tremendously over the years. However, it can still be a complex process particularly when building something like an administration control panel or a BI dashboard.

So how can we leverage existing tools for rapid form development?

We can use React Hook Form to create forms that are as easy to use as a normal HTML form, while using advanced patterns in form development with React and the React Hook Form library to eliminate boilerplate code, maximize reuse, and make the form development process more efficient. We can also use a component library together with React Hook Form to increase our efficiency even further. These component libraries become particularly useful for an admin dashboard, where the component requirements are all fairly standard. In the sample code that follows, you'll see that we leverage React Bootstrap in tandem with the React Hook Form library.

Edit User Dialog Form

What is React Hook Form?

React Hook Form is a library that allows us to take control of our React forms while eliminating a lot of the boilerplate. Additionally, it helps us organize our code in such a way that it becomes easier to read and maintain. It has in many ways become the defacto standard when it comes to building forms with React.

The documentation for React Hook Form is pretty good and there is no shortage of examples out there, so the focus of this article will be geared toward using a particular design pattern that allows for scalability and code reuse. This is an intermediate to advanced approach of form developent with React Hook Form, and it therefore assumes that you already have some working knowledge of the library.

Maximize Form Field Reusability for Rapid Form Development

For this use-case scenario, we're building an administration panel consisting of multiple forms throughout the application and we want the ability to quickly generate a form by simply defining an array of field object definitions. We'll then iterate over this array, mapping each object to a Field component that takes care of the rest.

Administration panels typically have a users page to list out all of the application's users, along with create and edit user dialogs. For our purposes we'll have two separate forms (create and edit) that share the same form fields, but the objective is for these components to be used for all of the forms in our administration control panel.

Create User Dialog Form

Create the Type Structure

Before we start building out the components, let's first define the types we will use for these components.

export enum TFieldType {
  Toggle = 'toggle',
  Textbox = 'textbox',
  Combobox = 'combobox',
  Date = 'date',
}
export type TComboboxOption = [string, string];

export type TField = {
  id: string;
  isRequired: boolean;
  controlType: TFieldType;
  label: string;
  defaultValue?: any;
  testId?: string;
  options?: TComboboxOption[];
  disabled?: boolean;
  control?: React.FC<any>;
};

type TFieldProps = {field: TField};

Here is a description of the properties listed above:

  • TFieldType - The type of field that we're creating. This is used to determine which component to use to render the field.
  • TComboboxOption - The type of the option that we're creating for a combobox. It's an array of two strings, the first being the value and the second being the label.
  • TField - This is the type of the field object that we're creating. It's an object that contains the following properties:
    • id - Id of the field. It's used to identify the field in the form when the form is submitted.
    • isRequired - Boolean that is used to define whether or not to display the asterisk next to the field and not allow form submission withoue a value.
    • controlType - The type of control from TFieldType that we're creating. This is used to determine which component to use to render the field.
    • label - The label for the field.
    • defaultValue - Default value that we'll use for the field.
    • testId - Test id that we'll use to identify the field in unit tests as needed.
    • options - Options that we'll use to create a combobox. It's an array of two strings, the first being the value and the second being the label.
    • disabled - Boolean that determines if the field is disabled.
    • control - This is the component that we'll use to render the field.

The Create and Edit forms require four field types as described by TFieldType. Of course, this can be further expanded as the control panel is built out and additional field types are needed.

Create the Field Component

The parent component will contain an array of field object definitions that map to the Field component shown below.

export const Field = ({field}: {field: TField}) => {
  const ControlComponent = getFieldControlComponent(field);
  return ControlComponent ? (
    <FieldRow
      key={field.id}
      required={field.isRequired}
      controlId={field.id}
      label={field.label}
      control={<ControlComponent field={field} />}
    />
  ) : (
    <field.control field={field} />
  );
};

The next thing we'll need is the ControlComponent and FieldRow. The FieldRow is responsible for the field's layout and displaying an asterisk if the field is required.

export const FieldRow: React.FC<{
  label: React.ReactNode;
  control: React.ReactNode;
  controlId: string;
  required?: boolean;
}> = ({label, control, controlId, required}) => (
  <Form.Group controlId={controlId} as={Grid.Row} className="mb-3">
    <Form.Label className="fieldCaption" column sm={4}>
      {required ? asterisk : null}
      {label}
    </Form.Label>
    <Grid.Col sm={8}>{control}</Grid.Col>
  </Form.Group>
);

Most of the UI components in these code samples come from react-bootstrap as a matter of convenience, but they can just as well be replaced with standard HTML elements or a different UI component library of your choosing.

Field defines a ControlComponent const which calls getFieldControlComponent. This method expects the TField object to be passed in and returns the component that should be used to render the field.

export const getFieldControlComponent = (field: TField) => {
  const mapping = {
    textbox: TextboxField,
    switch: SwitchField,
    combobox: ComboboxField,
    date: DateField,
  };
  if (field.controlType && mapping[field.controlType]) {
    return mapping[field.controlType];
  } else {
    return field.control ? null : mapping.textbox;
  }
};

An important thing to note is that controlType can be left empty and a custom control can be used. This is useful for one-off fields that are not part of the standard field set. If neither fields are defined, then the TextboxField component will be used.

Build the Form Container

Before we create the four field components, let's switch over to the CreateUserDialog component and see how that is laid out.

import React from 'react';
import {useForm, FormProvider} from 'react-hook-form';
import {TField, TFieldType, Field} from 'modules/settings/user-store/components/FormFields';

export const CreateUserDialog = () => {
  const form = useForm();

  return (
    <FormProvider {...form}>
      <Modal>
        <Form>
          <Modal.Header>
            <Modal.Title>
              <StringResource stringKey="UserStore_CreateUserTitle" />
            </Modal.Title>
          </Modal.Header>
          <Modal.Body>
            <FormContent />
          </Modal.Body>
          <Modal.Footer>
            <Stack gap={3} className="col-md-3 mx-auto" direction="horizontal">
              <GreenButton size="lg" type="submit">
                Create User
              </GreenButton>
            </Stack>
          </Modal.Footer>
        </Form>
      </Modal>
    </FormProvider>
  );
}

There are a couple of things worth noting here. First, we wrap the form with the FormProvider that we get from React Hook Form and pass it the useForm hook. This is important because it gives us access to the useFormContext hook, which we'll need in a bit when we create the form fields. Second, notice that the only thing we need to include in the modal body is a single FormContent component. This is what defines the fields to be displayed in the dialog. Here is what that looks like:

const FormContent = () => {
  return (
    <>
      {fields.map((field) => (
        <Field key={field.id} field={field} />
      ))}
    </>
  );
};

As you can see, FormContent is only responsible for mapping an array of fields to our Field component. The Field component is responsible for creating the actual fields as defined by the following array that is also located in the form container:

const fields: TField[] = [
  {
    id: 'userType',
    isRequired: true,
    controlType: TFieldType.Combobox,
    label: 'UserStore_UserType',
    options: [
      ['', 'Select user type'],
      ['Employee', 'Employee'],
      ['NonEmployee', 'NonEmployee'],
    ],
  },
  {
    id: 'testUser',
    isRequired: true,
    controlType: TFieldType.Switch,
    label: 'Test User',
    defaultValue: false,
  },
  {
    id: 'firstName',
    isRequired: true,
    controlType: TFieldType.Textbox,
    label: 'First Name',
  },
  {
    id: 'lastName',
    isRequired: true,
    controlType: TFieldType.Textbox,
    label: 'Last Name',
  },
  {
    id: 'email',
    isRequired: false,
    controlType: TFieldType.Textbox,
    label: 'Email',
  },
  {
    id: 'benefitType',
    isRequired: true,
    controlType: TFieldType.Combobox,
    label: 'Benefit Type',
    options: [
      ['', 'Select Benefit Type'],
      ['Subscriber', 'Subscriber'],
      ['Spouse', 'Spouse'],
      ['Dependent', 'Dependent'],
    ],
  },
  {
    id: 'birthday',
    isRequired: true,
    controlType: TFieldType.Date,
    label: 'Birth Date',
    testId: 'birthdate',
  },
  {
    id: 'subscriberId',
    isRequired: false,
    controlType: TFieldType.Textbox,
    label: 'Subscriber ID',
  },
  {
    id: 'managerId',
    isRequired: false,
    controlType: TFieldType.Textbox,
    label: 'Manager ID',
  },
  {
    id: 'country',
    isRequired: true,
    controlType: TFieldType.Textbox,
    label: 'Country',
  },
];

Hopefully things are starting to make sense now. We can essentially create forms by simply specifying an array of the fields that we want along with a few details about them.

Create the Textbox Field

Now we can create the actual fields, starting with the textbox.

export const TextboxField: React.FC<TFieldProps> = ({field}) => {
  const formHook = useFormContext();
  return (
    <>
      <Form.Control
        type="string"
        required={field.isRequired}
        defaultValue={field.defaultValue}
        disabled={field.disabled}
        {...formHook.register(field.id, {required: field.isRequired})}
      />
      {formHook.formState.errors[field.id] ? (
        <Form.Control.Feedback type="invalid">
          {field.isRequired ? "Field is required" : null}
        </Form.Control.Feedback>
      ) : null}
    </>
  );
};

The useFormContext hook gives us access to the form state and the all important register function, which is how React Hook Form keeps track of the form fields by using the field id as the key. The Form.Control and Form.Control.Feedback components come from react-bootstrap. Form.Control is just an HTML input with a few extras and the Form.Control.Feedback is just a div that styles itself based on the type that is passed to it. By checking formState.errors[field.id], we can display the validation error message if the form is submitted without the field input while isRequired is true.

Let's finish this off by adding the rest of the fields as defined by TFieldType:

export const ComboboxField: React.FC<TFieldProps> = ({field}) => {
  const formHook = useFormContext();
  const strings = useStrings();
  return (
    <>
      <Form.Select
        required={field.isRequired}
        defaultValue={field.defaultValue}
        {...formHook.register(field.id, {required: field.isRequired})}
      >
        {field.options?.map(([value, captionStringKey]) => {
          return (
            <option key={captionStringKey} value={value}>
              {strings[captionStringKey]}
            </option>
          );
        })}
      </Form.Select>
      {formHook.getFieldState(field.id).error ? (
        <Form.Control.Feedback type="invalid">
          {field.isRequired ? "Field is required." /> : null}
        </Form.Control.Feedback>
      ) : null}
    </>
  );
};

export const DateField: React.FC<TFieldProps> = ({field}) => {
  const formHook = useFormContext();
  return (
    <>
      <Form.Control
        type="date"
        data-testid={field.testId}
        required={field.isRequired}
        defaultValue={(field.defaultValue || '').split('T')[0]}
        {...formHook.register(field.id, {required: field.isRequired})}
      />
      {formHook.formState.errors[field.id] ? (
        <Form.Control.Feedback type="invalid">
          {field.isRequired ? "Field is required." /> : null}
        </Form.Control.Feedback>
      ) : null}
    </>
  );
};

export const SwitchField: React.FC<TFieldProps> = ({field}) => {
  const formHook = useFormContext();
  return (
    <Form.Check
      type="switch"
      defaultChecked={field.defaultValue}
      {...formHook.register(field.id)}
    />
  );
};

Adding a New Field

Adding a new field is a simple process. Let's say we want to add an EmailTextField that includes email address validation. All we need to do is add it to the TFieldType enum and add the component to the FormFields.tsx:

export const EmailTextField: React.FC<TFieldProps> = ({field}) => {
  const formHook = useFormContext();
  return (
    <>
      <Form.Control
        type="string"
        required={field.isRequired}
        defaultValue={field.defaultValue}
        disabled={field.disabled}
        {...formHook.register(field.id, {required: field.isRequired, pattern: {value: /^[^\s@]+@[^\s@]+\.[^\s@]+$/, message: "Email is invalid"}})}
      />
      {formHook.formState.errors[field.id] ? (
        <Form.Control.Feedback type="invalid">
           {formHook.formState.errors[field.id].message}
        </Form.Control.Feedback>
      ) : null}
    </>
  );
};

This is the same as our TextboxField, but it adds pattern validation to the register function and modifies the validation message to display any of the validation error messages.

Adding Validation

There are still some improvements that can be made to our fields to make them useful for any of the forms in the application, specifically with regard to validation. For example, we can avoid the need to create a separate EmailTextField component by expanding the TextboxField to support pattern validation. We can also make the type attribute a prop and include minLength, maxLength, min, and max to the validation object. The minLength and maxLength is useful for restricting the number of characters for a phone number or SSN field, for example. Additionally, having the option to set the field type to number and setting min and max validation allows us to use the same component for zip code, last 4 of SSN, and birthday with all of the necessary validation built in. Of course, for this to work you must also modify the TField type:

export type TFieldValidation = {
  required?: string;  //this is the error message string. If this exists, React Hook Form assumes that required is true.
  pattern?: {value: string, message: string};
  minLength?: {value: number, message: string};
  maxLength?: {value: number, message: string};
  min?: {value: number, message: string};
  max?: {value: number, message: string};
}
export type TField = {
  id: string;
  inputType: "string" | "number"
  controlType: TFieldType;
  label: string;
  defaultValue?: any;
  testId?: string;
  options?: TComboboxOption[];
  disabled?: boolean;
  control?: React.FC<any>;
  validation?: TFieldValidation;
};

export const TextboxField: React.FC<TFieldProps> = ({field}) => {
  return (
    <>
      <Form.Control
        type={field.inputType}
        required={field.isRequired}
        defaultValue={field.defaultValue}
        disabled={field.disabled}
        {...formHook.register(field.id, field.validation)}
      />
      {formHook.formState.errors[field.id] ? (
        <Form.Control.Feedback type="invalid">
          {formHook.formState.errors[field.id].message}
        </Form.Control.Feedback>
      ) : null}
    </>
  );
};

Now we have the option of setting the input type to number and defining all of the validation criteria for the field in field objects array.

For example, to create the email field we could do the following in the fields array:

{
  id: 'email',
  type: 'string',
  controlType: TFieldType.Textbox,
  label: 'Email',
  validation: {
    required: "This field is required",
    pattern: {value: /^[^\s@]+@[^\s@]+\.[^\s@]+$/, message: "Email is invalid"},
  },
},

Similarly, to create the phone number and zip code fields, we could do the following:

{
  id: 'phoneNumber',
  type: 'number',
  controlType: TFieldType.Textbox,
  label: 'Phone Number',
  validation: {
    required: "This field is required",
    pattern: {value: /^\d{3}-\d{3}-\d{4}$/, message: "Phone number is invalid"},
    minLength: {value: 10, message: "Phone number must be 10 digits"},
    maxLength: {value: 10, message: "Phone number must be 10 digits"},
  },
},
{
  id: 'zipCode',
  type: 'number',
  controlType: TFieldType.Textbox,
  label: 'Zip Code',
  validation: {
    required: "This field is required",
    pattern: {value: /^\d{5}$/, message: "Zip code is invalid"},
    minLength: {value: 5, message: "Zip code should be 5 digits"},
    maxLength: {value: 5, message: "Zip code should be 5 digits"},
  },
},

By doing this, we've also eliminated the need for a separate DateField component. However, It might be advantageous of us to keep the date field while modifying it to have an input mask because the standard html date input fails to meet accessibility standards. If your form needs to be accessible, then your best option for the DateField is to ditch the type = "date" route and use the IMaskInput from react-imask for the DateField component.

Another things worth considering is that you may actually want to have some of your fields defined instead of having a single generic textbox field to cut down on the amount of code you need to have in the property definitions of the field object in the fields array. For example, if you're not great with regex or you don't want your team members to have to think about that when they're defining form fields, you may actually want to have components for EmailField, PhoneNumberField, DateField, etc.

Building forms can be tedious and tricky, particularly when the application involves a lot of forms. However, you can make things easier on yourself by spending some time creating a strategy for how you're going to organize and structure your forms and form fields in advance. This will allow you to create a more efficient way to build your forms.