changed a year ago
Published Linked with GitHub

react-hook-form + Zod : Minimal input components and strong typing

I tried to find a good pattern to make efficients forms with fields control, strongly typed and minial TSX.
Actually, we have many big input components with many props and callbacks. Some use react-hook-form, some not. We have to navigate into multiple places to modify each input and give to them specific logics.
The goal of this reflexion is to harmonize and facilitate our forms.*

Instead of using specific logics on each input component, why no just validate fields through the form type ?

*react-hook-form allows to do that usingresolver in association with a typing library (Zod in our case). Thanks to that, we centralise the form validation in a Zod type.
We use zodResolver from @hookform/resolvers/zod


I hope it's approximately clear and it's a good usage of this pattern, it's a ok solution for our needs, maybe we could have another approach for our forms.
But anyway, a refacto is needed, or, at least, a documented standard
This document is certainly incomplete


Create and use forms

useForm

  const form = useForm<FormType>({
    mode: "all",
    resolver: zodResolver(ZodFormType),
  });

zodResolver is used to plug the ZodFormType validation

Accessing to the form everywhere with useFormContext

Instead of passing form as a prop to the bottom

Wrap the input components into a FormProvider

<FormProvider {...form}>
    ...inputs, form usage
</FormProvider>

Retreive the form in any inputs using useFormContext

const collectionForm = useFormContext<FormType>();

Correctly type Zod object

Create Zod form type

export const ZodFormtype = z.object({
    ...shape
})

Declare fields

Optional fields

Use optional()

Mandatory fields

Use .min(1)

Prevent blank value in fields

Use trim()

Refined validation

Use refine(). It allows to use more versatile validation than just Typescript representation (Using expression)

Optional field with no error if no value

In the case we want to validate the input value and show an error only if there is a value :

  • property1: z.string().trim().refine(!value || value => EAIL_REGEXP.test(value)).optional()

Custom error messages

In most of Zod methods, you can provide a second arguent as error message
Examples :

  • property2: z.string().trim().min(1, "This field is required")
  • property3: z.string().trim().min(1, "This field is required").refine(value => URL_REGEXP.test(value), "Only URLs are allowed")

Minimal usage of TextInput (string value)

It's recommended to make a component with these properies :

  • Generic type
  • name
  • form
  • placeholder
  • valueModifier
  • props for style,

Example :

<TextInput
    placeholder={placeHolder}
    onChangeText={(text) => 
        valueModifier ? 
        field.onChange(valueModifier(text)) :
        field.onChange(text)}
    value={field.value || ""}
/>

useController

const { fieldState, field } = useController<FormType>({
    name,
    control: form.control,
});

name

It must match the field name from the FormType declaration

control

Used to plug the form's values to the field

value

Set the TextInput as controlled component. We set the value prop to pass a default value "".
Prevents this error : A component is changing a controlled input to be uncontrolled

valueModifier

Used to force the front value. Example : Force the value to be uppercase. We can use toUppercase() in the ZodFormType, but it will not modify the front value in the form. So, we can use a valueModifier.

Handle array of fields with useFieldArray

If you have an array of objects in FormType like :

property4: {
    label: string
    description: string
}[]

You want to display X inputs that match property4 shape. You will use useFieldArray like this :

const { fields, update, append, remove } = useFieldArray({
    control: form.control,
    name: "property4",
});

And map each items to display one input per item :

{fields.map((item, index) => 
    ...TextInput usage with FormType and name (path) as explained above
)}

paths

This is the name, but for each item. Paths are accessible using the item's index. Typed Path<FormType>
Example, name will be
formProperty4.${itemIndex}.fieldProperty1

fields

All the property4 values

update

Used to change the property4 value (At itemIndex)

append

Used to add a property4 item

remove

Used to remove a property4 item (From itemIndex)

Usage of another inputs with <Controller/>

You can plug a TextInput to the form using useController.
But you could want to plug another component as input to the form.
Example : A DateTimeInput.
To do that, you will wrap the component into Controller, and pass the form control, the FormType and the field name to Controller

Example :

<Controller<FormType>
    control={form.control}
    name="property5"
    render={({ field: { onChange } }) => 
        <DateTimeInput
            onSelectDateTime={dateTime => onChange(dateTime)}
        />
    )}
/>

onChange

Used to set the field's value

value

The current field's value

Display field errors

Simply add this near each input component to show the field's errors (Default errors or custom errors specified in ZodFormType) :

<ErrorText>
    {form.getFieldState("property6").error?.message}
</ErrorText>
Select a repo