Dynamic hooks with react + react-hook-form and zod validation.

Handle forms in react with react-hook-form and validate the form with zod

ยท

6 min read

One of the main things in web-development is handling form, react-hook-form gives many.

Few important links:

  1. Link to a video explaining the code in youtube.

  2. Link for the github repo.

So first what is the question? well we want to create a form for a user to register a trip he made, you will have a required title field, a description field also required and a dynamic list of people that went to the trip with a minimum of one. Each person should have a name and email.

A) Create the schema with zod and infer the type

//src/schema/trip.schema.ts
import z, { object, number, string, TypeOf, date, array, infer } from 'zod';
export const createComplainSchema = object({
  title: string().min(1, { message: 'Title is required' }),
  description: string().min(1, { message: 'Description is required' }),
});

here we have created the zod schema with title and description and add validation where it needs to have a min of 1 and a message for error. We can use this schema to infer the typescript for the form object.

//src/schema/trip.schema.ts
import z, { object, number, string, TypeOf, date, array, infer } from 'zod';
export const createComplainSchema = object({
  title: string().min(1, { message: 'Title is required' }),
  description: string().min(1, { message: 'Description is required' }),
});
//NEW LINE
export type CreateTripSchemaType = TypeOf<typeof createTripSchema>;

But we also want to have a validation for people in the trip. S the schema will change to have a taggedPeople wish is an array of people.

//src/schema/trip.schema.ts
import z, { object, number, string, TypeOf, date, array, infer } from 'zod';

export const tagSchema = object({
  personName: string({
    required_error: 'Person name is required',
  }).min(1, { message: 'name is required' }),
  personEmail: string().min(1, { message: 'Email is required' }).email({
    message: 'Must be a valid email',
  }),
});

export const createTripSchema = object({
  title: string().min(1, { message: 'Title is required' }),
  description: string().min(1, { message: 'Description is required' }),
  taggedPeople: array(tagSchema),
});
export type CreateTripSchemaType = TypeOf<typeof createTripSchema>;
export type TagSchemaType = TypeOf<typeof tagSchema>;

Now we can see our form using, react-hook-form you can link the form type to the zod type so you can add the CreateTripSchemaType in the generic type of the useForm, and use the register to register an input. Notice if you change the name of the input from title to another, it will show an error because of the typescript.

//src/App.tsx
import { SubmitHandler, useFieldArray, useForm } from 'react-hook-form';
function App() {
  const { register, handleSubmit, control, formState: { errors } } = useForm<CreateTripSchemaType>({
  });
return (
   <div
      className='bg-red-50 flex
       flex-col items-center justify-center h-screen w-screen'
    >
        <div className="mb-4">
          <label htmlFor="title" className="block text-gray-700 text-sm font-bold mb-2">
            Trip title
          </label>
          <input
            type="text"
            id="title"
            className="w-full px-3 py-2 border rounded focus:outline-none focus:shadow-outline"
            placeholder="Title for your trip"
            {...register('title')}
          />
          {errors.title && <span className='text-red-500 text-xs'>{errors.title.message}</span>}
        </div>

    </div>
    )
}

export default App

But we want to also validate the form with our schema and for this we use @hookform/resolvers

  const { register, handleSubmit, control, formState: { errors } } = useForm<CreateTripSchemaType>({
    resolver: zodResolver(createTripSchema),
  });

So adding this resolver and passing our schema, react-hook-form will use it to validate, but how will it know when the form is submitted

 <form
  onSubmit={handleSubmit(submitForum)}
  className='flex flex-col w-[500px] bg-white p-4 rounded-md shadow-md'
 ></form>

It knows by passing the handleSubmit from react-hook-form to the form wish will handle everything and then call the callback function that you put as a param

import { SubmitHandler, useFieldArray, useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { CreateTripSchemaType, TagSchemaType, createTripSchema } from './schema/trip.schema';



function App() {
  const { register, handleSubmit, control, formState: { errors } } = useForm<CreateTripSchemaType>({
    resolver: zodResolver(createTripSchema),
  });

  const submitForum: SubmitHandler<CreateTripSchemaType> = (data) => {
    //TODO: submit data to server
    console.log(data)
  }
  return (
    <div
      className='bg-red-50 flex flex-col items-center justify-center h-screen w-screen'
    >
      <form
        onSubmit={handleSubmit(submitForum)}
        className='flex flex-col w-[500px] bg-white p-4 rounded-md shadow-md'
      >
        <h2 className="text-2xl font-bold mb-4">Register Experience</h2>

        <div className="mb-4">
          <label htmlFor="title" className="block text-gray-700 text-sm font-bold mb-2">
            Trip title
          </label>
          <input
            type="text"
            id="title"
            className="w-full px-3 py-2 border rounded focus:outline-none focus:shadow-outline"
            placeholder="Title for your trip"
            {...register('title')}
          />
          {errors.title && <span className='text-red-500 text-xs'>{errors.title.message}</span>}
        </div>
        <div className="mb-4">
          <label htmlFor="description" className="block text-gray-700 text-sm font-bold mb-2">
            Trip description
          </label>
          <input
            type="text"
            id="description"
            className="w-full px-3 py-2 border rounded focus:outline-none focus:shadow-outline"
            placeholder="Description for your trip"
            {...register('description', { required: true })}
          />
          {errors.description && <span className='text-red-500 text-xs'>{errors.description.message}</span>}
        </div>

        <p className='mt-4 text-xs'>Add people that went on the trip</p>

        <button
          className='mt-4 bg-slate-400 p-2 border border-solid rounded-md hover:bg-slate-300'
          type='submit'
        >Submit</button>
      </form >
    </div >
  )
}

export default App

Now lets handle the dynamic part of the form, react-hook-form gives a useFieldArray wish handles the dynamic parte, you need to link it with the useForm by using the control and the name should be as the type in the schema wish is taggedPeople notice that the taggedPeople MUST be an array.

  const { fields, append, remove } = useFieldArray({
    control,
    name: 'taggedPeople'
  })

And the final code is

import { SubmitHandler, useFieldArray, useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { CreateTripSchemaType, TagSchemaType, createTripSchema } from './schema/trip.schema';



function App() {
  const { register, handleSubmit, control, formState: { errors } } = useForm<CreateTripSchemaType>({
    resolver: zodResolver(createTripSchema),
  });
  const { fields, append, remove } = useFieldArray({
    control,
    name: 'taggedPeople'
  })

  const addComplain = (chosenRow: TagSchemaType) => {
    append(chosenRow)
  }
  const submitForum: SubmitHandler<CreateTripSchemaType> = (data) => {
    //TODO: submit data to server
    console.log(data)
  }
  return (
    <div
      className='bg-red-50 flex flex-col items-center justify-center h-screen w-screen'
    >
      <form
        onSubmit={handleSubmit(submitForum)}
        className='flex flex-col w-[500px] bg-white p-4 rounded-md shadow-md'
      >
        <h2 className="text-2xl font-bold mb-4">Register Experience</h2>

        <div className="mb-4">
          <label htmlFor="title" className="block text-gray-700 text-sm font-bold mb-2">
            Trip title
          </label>
          <input
            type="text"
            id="title"
            className="w-full px-3 py-2 border rounded focus:outline-none focus:shadow-outline"
            placeholder="Title for your trip"
            {...register('title')}
          />
          {errors.title && <span className='text-red-500 text-xs'>{errors.title.message}</span>}
        </div>
        <div className="mb-4">
          <label htmlFor="description" className="block text-gray-700 text-sm font-bold mb-2">
            Trip description
          </label>
          <input
            type="text"
            id="description"
            className="w-full px-3 py-2 border rounded focus:outline-none focus:shadow-outline"
            placeholder="Description for your trip"
            {...register('description', { required: true })}
          />
          {errors.description && <span className='text-red-500 text-xs'>{errors.description.message}</span>}
        </div>
        <div className='mt-2 max-h-[300px] overflow-y-auto'>
          {
            fields.map((field, index) => {
              const hasNameError = errors.taggedPeople?.[index]?.personName
              const hasEmailError = errors.taggedPeople?.[index]?.personEmail
              return (
                <div key={field.id} className='flex items-center gap-2 mt-2'>
                  <input
                    type="text"
                    id="description"
                    className={`
                    ${hasNameError ? 'border-red-500' : ''}
                    w-full px-3 py-2 h-12 border rounded focus:outline-none focus:shadow-outline`}
                    placeholder="participant name"
                    {...register(`taggedPeople.${index}.personName`, { required: true })}

                  />

                  <input
                    type="email"
                    id="description"
                    className={`
                    ${hasEmailError ? 'border-red-500' : ''}
                    w-full px-3 py-2 border h-12 rounded focus:outline-none focus:shadow-outline`}
                    placeholder="participant email"
                    {...register(`taggedPeople.${index}.personEmail`,
                      { required: true, })}
                  />
                  <p
                    className='text-red-500 cursor-pointer'
                    onClick={() => {
                      remove(index)
                    }}>Remove</p>
                </div>
              )
            })
          }
        </div>
        <p className='mt-4 text-xs'>Add people that went on the trip</p>
        <button
          type='button'
          className='mt-1 w-[200px] bg-white p-2 border border-solid rounded-md'
          onClick={() => {
            addComplain({
              personName: '',
              personEmail: ''
            })
          }}
        >Add Field</button>
        <button
          className='mt-4 bg-slate-400 p-2 border border-solid rounded-md hover:bg-slate-300'
          type='submit'
        >Submit</button>
      </form >
    </div >
  )
}

export default App

PS: notice the structure of the dynamic part name wish is taggedPeople.${index}.personName this index will handle the dynamic part.

ย