Krzysztof Ciach's

personal website

programing stuff

< back

How to reuse groups of fields / fieldsets of a complex form in React with TypeScript and react-hook-form.

The problem

You have a complex form with groups of fields and you want to reuse some of them in another form and you want to keep strong typing and good developer experience - like autocompletion and type checking.

A case

You have a small e-commerce app, where a client during checkout needs to fill address form, but also can provide diffrent address for billing. You want to reuse address form for billing address.

Also you realised it would be great to reuse address form for user profile form.

Prerequirments

You have to know basics of: react, typescript, react-hook-form, zod.

Form design

You have a form with fields:

  • name
  • surname
  • phone
  • address
  • zip code
  • city

Code

Let's use react-hook-form for building and handling form and zod for validation. First we will create a schema for it.

For the sake of simplicity we validate only if fields are not empty.

// form-schemas.ts

import { z } from 'zod';

export const addressSchema = z.object({
  name: z.string(),
  surname: z.string(),
  phone: z.string(),
  address: z.string(),
  zipCode: z.string(),
  city: z.string(),
});

export type AddressFieldset = z.infer<typeof addressSchema>;

export const checkoutFormSchema = z.object({
  email: z.string().email(),
  address: addressSchema,
  billingAddress: addressSchema,
});

export type CheckoutForm = z.infer<typeof checkoutFormSchema>;

export const profileFormSchema = z.object({
  email: z.string().email(),
  isCompany: z.boolean(),
  address: addressSchema,
});

export type ProfileForm = z.infer<typeof profileFormSchema>;

Now we can create a form with address fields.

// CheckoutForm.tsx

import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { checkoutFormSchema } from './form-schemas';

export default function CheckoutForm() {
  const { register, handleSubmit } = useForm({
    resolver: zodResolver(checkoutFormSchema),
  });

  return (
    <form>
      <ul>
        <li>
          <label>Email</label>
          <input {...register('email')} />
        </li>
        <li>
          <h3>Address</h3>
        </li>
        <li>
          <label>Name</label>
          <input {...register('address.name')} />
        </li>
        <li>
          <label>Surname</label>
          <input {...register('address.surname')} />
        </li>
        <li>
          <label>Phone</label>
          <input {...register('address.phone')} />
        </li>
        <li>
          <label>Address</label>
          <input {...register('address.address')} />
        </li>
        <li>
          <label>Zip code</label>
          <input {...register('address.zipCode')} />
        </li>
        <li>
          <label>City</label>
          <input {...register('address.city')} />
        </li>
        <li>
          <h3>Billing address</h3>
        </li>
        <li>
          <label>Name</label>
          <input {...register('billingAddress.name')} />
        </li>
        <li>
          <label>Surname</label>
          <input {...register('billingAddress.surname')} />
        </li>
        <li>
          <label>Phone</label>
          <input {...register('billingAddress.phone')} />
        </li>
        <li>
          <label>Address</label>
          <input {...register('billingAddress.address')} />
        </li>
        <li>
          <label>Zip code</label>
          <input {...register('billingAddress.zipCode')} />
        </li>
        <li>
          <label>City</label>
          <input {...register('billingAddress.city')} />
        </li>
        <li>
          <button type="submit">Submit</button>
        </li>
      </ul>
    </form>
  );
};

It works, but violates DRY, is hard to read and if would like to make ui more fancy for address fields, you would need to change it in multiple places. We can extract address fields to a separate component and as documentation of react-hook-form suggests we can use useFormContext.

First refactor

// AddressFields.tsx

import { CheckoutForm } from './form-schemas';

export default function AddressFields({ prefix }: { prefix: 'address' | 'billingAddress' }) {
  const { register } = useFormContext<CheckoutForm>()

  return (
    <>
      <li>
        <label>Name</label>
        <input {...register(`${prefix}.name` as const)} />
      </li>
      <li>
        <label>Surname</label>
        <input {...register(`${prefix}.surname` as const)} />
      </li>
      <li>
        <label>Phone</label>
        <input {...register(`${prefix}.phone` as const)} />
      </li>
      <li>
        <label>Address</label>
        <input {...register(`${prefix}.address` as const)} />
      </li>
      <li>
        <label>Zip code</label>
        <input {...register(`${prefix}.zipCode` as const)} />
      </li>
      <li>
        <label>City</label>
        <input {...register(`${prefix}.city` as const)} />
      </li>
    </>
  );
}

// CheckoutForm.tsx

import { useForm, FormProvider } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';

export default function CheckoutForm() {
  const methods = useForm({
    resolver: zodResolver(checkoutFormSchema),
  });

  const { register } = methods;

  return (
    <FormProvider {...methods}>
      <form>
        <ul>
          <li>
            <label>Email</label>
            <input {...register('email')} />
          </li>
          <li>
            <h3>Address</h3>
          </li>
          <AddressFields prefix="address" />
          <li>
            <h3>Billing address</h3>
          </li>
          <AddressFields prefix="billingAddress" />
          <li>
            <button type="submit">Submit</button>
          </li>
        </ul>
      </form>
    </FormProvider>
  );
};

Ok. It looks better now, but here's a problem - we cannot reuse AddressFields in ProfileForm because it's strongly typed by CheckoutForm. We can fix that by a more generic type for useFormContext. But what if one would like to go with AddressFields in a flat structure (schema) of a form or in a deeply nested components tree?

My solution

First of all, I tried to figure out how to make AddressFields "knows" nothing about the form structure. I can pass register function as a prop and custom register function should be simplified and typed to not form schema, but only to the fieldset schema.

// AddressFields.tsx

import { FieldPath, UseFormRegisterReturn } from 'react-hook-form';

import { AddressFieldset } from './form-schemas';

export interface AddressFieldsProps {
  register: (name: FieldPath<AddressFieldset>) => UseFormRegisterReturn;
  // UseFormRegisterForm is also generic, but it uses generic type for `name` attribute only.
  // What in most of cases, imo, don't have to be strong typed - default `string` is enough.
}

export default function AddressFields({ register }: AddressFieldsProps) {
  return (
    <>
      <li>
        <label>Name</label>
        <input {...register('name')} />
      </li>
      <li>
        <label>Surname</label>
        <input {...register('surname')} />
      </li>
      <li>
        <label>Phone</label>
        <input {...register('phone')} />
      </li>
      <li>
        <label>Address</label>
        <input {...register('address')} />
      </li>
      <li>
        <label>Zip code</label>
        <input {...register('zipCode')} />
      </li>
      <li>
        <label>City</label>
        <input {...register('city')} />
      </li>
    </>
  );
}

Super simple and reusable for any form schema with address fields, but in this case we need to adjust CheckoutForm to pass register function.

// CheckoutForm.tsx

import { useForm, FormProvider } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';

export default function CheckoutForm() {
  const methods = useForm({
    resolver: zodResolver(checkoutFormSchema),
  });

  const { register } = methods;

  return (
    <FormProvider {...methods}>
      <form>
        <ul>
          <li>
            <label>Email</label>
            <input {...register('email')} />
          </li>
          <li>
            <h3>Address</h3>
          </li>
          <AddressFields register={
            // All is typed and can be autocompleted!
            (name) => register(`address.${name}`)
          }/>
          <li>
            <h3>Billing address</h3>
          </li>
          <AddressFields register={
            (name) => register(`billingAddress.${name}`)
          } />
          <li>
            <button type="submit">Submit</button>
          </li>
        </ul>
      </form>
    </FormProvider>
  );
};

That simple! Notice that if AddressFields would be changed in the future, form itself doesn't have to be adjusted.

Sum up!

In this article, we explored how to reuse groups of form fields in a React application using TypeScript and react-hook-form, while maintaining strong typing and a good developer experience.

Key Steps:

  1. Define Schemas: Create a schema for address fields and forms using zod.
  2. Make component generic and independent from form structure by passing custom register function as a prop.
  3. Pass register function to the component from the form.

Conclusion

It's a great way to keep your code DRY, easy to read and separated concerns. Also it keeps strong typing and good developer experience.

You can use this approach for form errors as well. It is suitable for more nested forms.

PS. I've also used the above approach with Mantine library form.


↑ top | < back