DEV Community

Kirby Aguilar
Kirby Aguilar

Posted on

Reusable form inputs with React Hook Form and TypeScript

When it came time to introduce a form validation framework to my previous company's app, we actually settled on TanStack Form. We took one swing at it, and immediately felt that the syntax was not for us. Sorry Tanner.

So we went back to the drawing board, and React Hook Form it was! One of us made a proof of concept by integrating it with our login form. It worked and it looked good, so the decision was made to continue using the library.

What we overlooked

What no one realized at the time was that our login and signup forms were the oldest forms in the entire app. Hence, they were the only ones not using our reusable <FormInput> components, which we used everywhere else. They looked a little bit like this:

// OldDeprecatedUncoolLoserFormInput.tsx

const FormInput: React.FC<FormInputProps> = ({
  type,
  inputName,
  labelText,
  isRequired,
  isDisabled,
  value,
  onChange,
}) => {
  return (
    <div className="flex flex-col gap-1">
      <label htmlFor={inputName} className="text-xs">{labelText}{isRequired && '*'}</label>
      <input type={type} id={inputName} name={inputName}
        className="input input-sm input-bordered"
        value={value}
        onChange={onChange}
        required={isRequired}
        disabled={isDisabled}
      />
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

It's no surprise that we couldn't use these as-is with RHF. Below is a comparison of the two:

{/* Here's how we used our old form inputs */}
<FormInput
  type="date"
  inputName="arbitraryFieldName"
  labelText="arbitrary field name"
  value={someFormData.arbitraryFieldName}
  onChange={someOnChangeInputFunction}
/>

{/* ..and here's an example from the RHF docs. When working with RHF, you need to pass in register to the input tag */}
<form onSubmit={handleSubmit(onSubmit)}>
  <input defaultValue="test" {...register("example")} />
</form>
Enter fullscreen mode Exit fullscreen mode

Our forms, our onChange handlers for the forms' inputs, and the inputs themselves weren't built to work with RHF. Some major refactoring was in order. This responsibility fell onto me. Surprisingly, there were very few guides that actually went into solving this problem. Do people just not make reusable form inputs with RHF?

As we go on to mention in the references, this guide has adapted a lot from Keionne Derousselle's blog post, which was critical in solving this problem when I first encountered it. This guide is meant to be a more condensed (and slightly updated) version to its predecessor.

What we want to achieve

  1. Create a new, reusable form input component that works with RHF
  2. Disentangle inputs from the new reusable form input component. This means creating an <Input> component separate from <FormInput>
  3. At the same time, we want a more robust <FormInput> that can show errors and accept class names for styling
  4. We also want to demonstrate how one might tie RHF, RHF resolvers and react-query together.

Skipping to the end

If you're in a hurry and want a bird's eye view of the code without any of the explanations, you can go straight to the GitHub repository for this project.

Other tools we'll work with

Besides RHF and TypeScript, we'll be working with Vite, Tailwind CSS, react-query, zod, and classnames.

Project setup

$ npm create vite@latest reusable-rhf-form-inputs -- --template react-ts
$ cd reusable-rhf-form-inputs && npm install
$ npm install react-hook-form @hookform/error-message 
 @hookform/resolvers @tanstack/react-query zod classnames

# remove boilerplate files that we do not need 
$ cd src && rm -rf assets/ App.css index.css && cd ..
Enter fullscreen mode Exit fullscreen mode

You can opt to follow the regular installation instead if you'd like, but for this guide we'll be using the tailwind CDN installation:

<!-- slot this into your <head> at src/index.html -->
<script src="https://cdn.tailwindcss.com"></script>
Enter fullscreen mode Exit fullscreen mode

Let's edit some starter files as well:

// src/main.tsx
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import App from './App'

// used later for react-query
const queryClient = new QueryClient();

createRoot(document.getElementById('root')!).render(
  <StrictMode>
    <QueryClientProvider client={queryClient}>
      <App />
    </QueryClientProvider>
  </StrictMode>,
)


// src/App.tsx
export default function App() {
  return (
    // confirm that tailwind is installed
    <h1 className="text-indigo-500">Hello world!</h1>
  )
}
Enter fullscreen mode Exit fullscreen mode

We're ready to start! Let's carry on.

The <Input> Component

We start by defining a generic input component that isn't necessarily tied to an RHF form. We'll create our <FormInput> later based on this.

import { forwardRef, DetailedHTMLProps, InputHTMLAttributes } from 'react';
import classNames from 'classnames';

export type InputType = 'text' | 'email' | 'date' | 'time' | 'datetime' | 'number';

export type InputProps = {
  id: string;
  name: string;
  label: string;
  type?: InputType;
  className?: string;
  placeholder?: string;
} & DetailedHTMLProps<InputHTMLAttributes<HTMLInputElement>, HTMLInputElement>;

export const Input: React.FC<InputProps> = forwardRef<HTMLInputElement, InputProps>((
  {
    id,
    name,
    label,
    type = 'text',
    className = '',
    placeholder,
    ...props
  },
  ref
) => {
  return (
    <input
      id={id}
      ref={ref}
      name={name}
      type={type}
      aria-label={label}
      placeholder={placeholder}
      // change this to however inputs should be styled in your project
      className={classNames(
        'block w-full px-3 py-2 border border-gray-300 rounded-md text-sm focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 placeholder-gray-400 transition',
        className
      )}
      {...props}
    />
  );
});
Enter fullscreen mode Exit fullscreen mode

We can change our App.tsx to show the changes we've made so far:

// src/App.tsx

import { Input } from "./components/Input";

export default function App() {
  return (
    <div className="w-96 p-4">
      <h1 className="text-indigo-500 mb-4">Form with basic inputs</h1>

      <form className="flex flex-col gap-2">
        <Input id="first_name" name="first_name" label="first_name" />
        <Input id="employee_number" name="employee_number" label="employee_number" type="number" />

        <button type="submit" className="rounded-md text-white bg-blue-500 hover:bg-blue-600 p-2">Submit</button>
      </form>
    </div>
  )
}
Enter fullscreen mode Exit fullscreen mode

You should be able to run npm run dev and see our inputs in action.

<FormInput> and the types that come with it

Here's our <FormInput> component:

// src/components/FormInput.tsx

import {
  Path,
  UseFormRegister,
  FieldValues,
  DeepMap,
  FieldError,
} from "react-hook-form";
import { ErrorMessage } from "@hookform/error-message";
import { Input, InputProps } from "./Input";

export type FormInputProps<TFormValues extends FieldValues> = {
  name: Path<TFormValues>;
  outerDivClassName?: string;
  innerDivClassName?: string;
  inputClassName?: string;
  register?: UseFormRegister<TFormValues>;
  errors?: Partial<DeepMap<TFormValues, FieldError>>;
  isErrorMessageDisplayed?: boolean;
} & Omit<InputProps, 'name'>;

const FormInput = <TFormValues extends FieldValues>({
  outerDivClassName,
  innerDivClassName,
  inputClassName,
  name,
  label,
  register,
  errors,
  isErrorMessageDisplayed = true,
  ...props
}: FormInputProps<TFormValues>): JSX.Element => {
  return (
    <div className={outerDivClassName}>
      <div className={innerDivClassName}>
        <label htmlFor={name} className="text-xs">{label}{props.required && '*'}</label>
        <Input
          name={name}
          label={label}
          className={inputClassName}
          { ...props }
          { ...(register && register(name)) }
        />
      </div>
      { isErrorMessageDisplayed && <ErrorMessage
          errors={errors}
          name={name as any}
          render={({ message }) => (
            <p className="text-xs text-deya-red-500">
              {message}
            </p>
          )}
        /> }
    </div>
  );
}

export default FormInput;
Enter fullscreen mode Exit fullscreen mode

There's certainly a lot to parse from FormInputProps! Let's take it one step at a time.

The fields below are fairly simple and self-explanatory. We'll keep the discussion on the other parts.

export type FormInputProps = {
  // className props for styling different parts of the FormInput
  outerDivClassName?: string;
  innerDivClassName?: string;
  inputClassName?: string;

  // use cautiously, in cases where we don't want to display the error message, such as if you want to display
  // all error messages in a central area in your form, or if there are no errors possible
  // e.g. a checkbox where false and true are both valid
  isErrorMessageDisplayed?: boolean;
} & Omit<InputProps, 'name'>; // intersection with InputProps EXCEPT name
Enter fullscreen mode Exit fullscreen mode

Some of our props are going to depend on what our form actually looks like; hence, we need to pass in a form values generic type. This type TFormValues also must inherit from the type FieldValues to be compatible with the register prop later on.

export type FormInputProps<TFormValues extends FieldValues> = {
  // ...
} & Omit<InputProps, 'name'>;

// FieldValues type for reference
type FieldValues = {
  [x: string]: any;
}
Enter fullscreen mode Exit fullscreen mode

Case in point, our name prop, which curiously isn't of type String. If you're familiar with RHF, you might be familiar with syntax for getting values like root.arrayField.[0].fieldName or the more readable root.employees.[0].first_name. Similarly, our name must represent the exact path to the field, given TFormValues.

export type FormInputProps<TFormValues extends FieldValues> = {
  name: Path<TFormValues>;
  // ...
} & Omit<InputProps, 'name'>;
Enter fullscreen mode Exit fullscreen mode

Finally, we have the register prop to register our input to a form, and our errors prop.

export type FormInputProps<TFormValues extends FieldValues> = {
  // ...
  register?: UseFormRegister<TFormValues>;
  errors?: Partial<DeepMap<TFormValues, FieldError>>;
  // ...
} & Omit<InputProps, 'name'>;
Enter fullscreen mode Exit fullscreen mode

DeepMap is a type under RHF that recursively maps the keys of an object (or form) to some value. This means that it would change your hypothetical form this way:

const formValues = {
  user: {
    name: '',
    address: {
      street: '',
      city: ''
    }
  }
};

const errors = {
  user: {
    name: SomeFieldErrorOrNothing,
    address: {
      street: { message: "Street is required" },
      city: SomeFieldErrorOrNothing,
    }
  }
};
Enter fullscreen mode Exit fullscreen mode

But as you may have noticed from that snippet, we won't always have field errors for every part of the form; hopefully, we won't have errors at all! This is where Partial comes in, which trims things down to only what's necessary:

const errors = {
  user: {
    address: {
      street: { message: "Street is required" },
    }
  }
};
Enter fullscreen mode Exit fullscreen mode

One notable difference between this guide and its predecessor is that we forego the usage of a rules property that would be passed to our FormInput. This is because we will make use of an RHF resolver on the form level instead.

...and that's it! We now have a universal <FormInput> component that we can use anywhere within our app.

Prerequisites for the form

Before we move on to creating a form and tying all of our moving parts together, we have some boxes to tick off:

Creating some types

// src/types.ts

export type EmployeeForm = {
  first_name: String;
  employee_number: Number;
}

export type EmployeeCreateRequest = {
  employee: EmployeeForm;
}

export const defaultEmployeeFormValues = {
  first_name: '',
  employee_number: 0,
}
Enter fullscreen mode Exit fullscreen mode

Creating a hook to mock db operations

// src/hooks.ts

import { useMutation } from "@tanstack/react-query"
import { EmployeeCreateRequest } from "./types"

const createEmployee = async(req: EmployeeCreateRequest) => {
  // normally you'd want to make an axios request to your backend API here
  console.log(req);
}

export const useCreateEmployee = () => {
  return useMutation({
    mutationFn: (req: EmployeeCreateRequest) => createEmployee(req)
  })
}
Enter fullscreen mode Exit fullscreen mode

Writing validation

// src/validation.ts

import * as z from "zod";

export const createEmployeeSchema = z.object({
  first_name: z.string().min(1).max(20),
  employee_number: z.coerce.number().refine(val => Number(val) >= 0 , 'The number must not be less than 0'),
});
Enter fullscreen mode Exit fullscreen mode

Putting everything together

With all that being done, we can finally write our form:

// src/App.tsx

import { DeepMap, FieldError, useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";

import { createEmployeeSchema } from "./validation";
import { EmployeeCreateRequest, EmployeeForm, defaultEmployeeFormValues } from "./types";
import { useCreateEmployee } from "./hooks";
import FormInput from "./components/FormInput";

export default function App() {

  // you can use variable names like `register` as-is, but this is more explicit
  // and will come in handy whenever you need more than 1 form in your component
  const {
    register: createEmployeeFormRegister,
    handleSubmit: createEmployeeFormHandleSubmit,
    getValues: createEmployeeFormGetValues,
    formState: createEmployeeFormState,
  } = useForm<EmployeeForm>({
    defaultValues: defaultEmployeeFormValues,
    mode: 'onBlur',
    resolver: zodResolver(createEmployeeSchema)
  });

  // we must ensure that errors are coerced to the right type
  const { errors: createEmployeeFormErrors } = createEmployeeFormState as {
    errors: Partial<DeepMap<EmployeeForm, FieldError>>
  };

  // simulate a request to the DB on submit
  const createEmployeeMutation = useCreateEmployee();
  const onCreateEmployeeFormSubmit = createEmployeeFormHandleSubmit(() => {
    const payload: EmployeeCreateRequest = { employee: createEmployeeFormGetValues() }
    createEmployeeMutation.mutate(payload);
  })

  return (
    <div className="w-96 p-4">
      <h1 className="text-indigo-500 mb-4">Form with basic inputs</h1>

      <form onSubmit={onCreateEmployeeFormSubmit} className="flex flex-col gap-2">
        <FormInput<EmployeeForm> // don't forget to pass in TFormValues to your FormInput!
          id="first_name"
          name="first_name" // note that here you might pass something like employees.[0].first_name as well
          label="First Name"
          register={createEmployeeFormRegister}
          errors={createEmployeeFormErrors}
          required={true}
        />

        <FormInput<EmployeeForm>
          type="number"
          id="employee_number"
          name="employee_number"
          label="Employee Number"
          register={createEmployeeFormRegister}
          errors={createEmployeeFormErrors}
          required={true}
        />

        <button type="submit" className="rounded-md text-white bg-blue-500 hover:bg-blue-600 p-2">Submit</button>
      </form>
    </div>
  )
}
Enter fullscreen mode Exit fullscreen mode

That should be it! You can expect error messages to show right underneath the form inputs. Otherwise, if your form is valid, submitting should log its contents to the console.

Image description

Cheers!

References

  • Keionne Derousselle's blog post was the inspiration for this guide, and a lot of what I say here is taken directly from what I learned from their post. None of this would have been possible without them--literally, I couldn't find any other guides that were helpful to me at the time--if you're here looking for answers to a problem you have, you might have come across their post already. If you haven't, then do give the post a read!
  • Official documentation for react-hook-form

Top comments (0)