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.
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.
You have to know basics of: react
, typescript
, react-hook-form
, zod
.
You have a form with fields:
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
.
// 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?
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.
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.
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.