DEV Community

Cover image for How to create composable forms using React Hook Form, Compound Components and Zod
Patrice Gauthier
Patrice Gauthier

Posted on

How to create composable forms using React Hook Form, Compound Components and Zod

In this article I will show you how to use advanced React concepts to have a form with reusable components, validation and have it share data between components. This will avoid abuse of prop drilling and of context while allowing to compose our form. It will be as much as it can be valid with Typescript.

Compound Components

First Compound Components is a way to explain to the reader there's a parent-child relationship between component. It makes it so that you have to define the parent before you define the child. There's a whole article about that on Smashing Magasine. Basically it allows us to have components like below where you know you have to create a Form component before using the inputs. The reader can also deduce those components are reusable.

<Form onSubmit={onSubmit}>
  <Form.Input name="firstName" label="First name" />
  <Form.Input name="lastName" label="Last name" />
  <Form.Submit type="button"/>
</Form>
Enter fullscreen mode Exit fullscreen mode

Composing your form with reusable components

To make your form reusable, you have to create components that are reusable and also you should be able to compose your form as you need. For this React Hook Form provides a small example. Here's a screenshot of it.
Screenshot of smart form component on React Hook Form website

There's a problem with this solution though. It creates every child component passing it the register function and so it requires that every child component is an HTML input or select.
Code screenshot of how limited is the Form from their webiste
Animation of trying to set a div in the form and it gives an error

This can be circumvented by using another API from their documentation.

Use form context

Using form context allows to create child components no matter how deep they are. You wrap your form with a <FormProvider> passing all the methods

export function Form({
  schema,
  onSubmit,
  children,
  defaultValues
}: {
  schema: any
  onSubmit: (data: Record<string, any>, event?: React.BaseSyntheticEvent) => void
  children: any
  defaultValues?: Record<string, any>
}) {
  const methods = useForm({
    defaultValues,
    resolver: zodResolver(schema)
  })
  const handleSubmit = methods.handleSubmit

  return (
    <FormProvider {...methods}>
      <form onSubmit={handleSubmit(onSubmit)}>
        {children}
      </form>
    </FormProvider>
  )
}
Enter fullscreen mode Exit fullscreen mode

Now we can have an <Input> defined as below where we get the register function that is needed to link the input to the React Hook Form and some other state like errors and isSubmitting. With this error handling is within the component and the input gets locked when we submit.

Form.Input = function Input({
  name,
  displayName,
  type
}: {
  name: string
  displayName: string
  type: string
}) {
  const {
    register,
    formState: { isSubmitting, errors }
  } = useFormContext()

  return (
    <div>
      <label className="block">
        <span className="block">{displayName}</span>
        <input
          type={type}
          {...register(name)}
          disabled={isSubmitting}
        />
      </label>
      {errors[name as string] && (
        <p className="error">{errors[name as string]?.message}</p>
      )}
    </div>
  )
}
Enter fullscreen mode Exit fullscreen mode

Assign a schema for validation

For this form to be reusable and valid we want to do validation on the inputs. React Hook Form provide their own simple validation but here we will use zod as schema validation. This makes the form ready to handle more complex validation.
Adding validation can be done by passing the validation schema to the Form component.

+ import { zodResolver } from "@hookform/resolvers/zod"
...
function Form({
+ schema,
...
}: {
+ schema: any
...
}
  const methods = useForm({
    defaultValues,
+    resolver: zodResolver(schema)
  })
Enter fullscreen mode Exit fullscreen mode
export const FormSchema = z.object({
  email: z.string().email(),
  username: z.string().min(3, { message: "Must be more than 3 characters" }),
  pizzaChoice: z.string(),
  accept: z.literal(true, {
    errorMap: () => ({
      message: "You must accept Terms and Conditions."
    })
  }),
  tier: z
    .string({ invalid_type_error: "Please select a payment tier." })
    .refine((val) => Tiers.map((tier) => tier.id).includes(val))
})

<Form schema={FormSchema} onSubmit={onSubmit} defaultValues={someInitialValues}>
...
</Form>
Enter fullscreen mode Exit fullscreen mode

Live example with Typescript validation

I have a live example with valid Typescript and the name attributes needs to be one of the keys of the schema.
Validation highlight of passing invalid name for input

You can find the live example here

Discussion (0)