psygo
psygo

Reputation: 7583

Creatable Fields with React Hook Form and Zod (and Shad UI)

In my form, I would like the user to have the ability to add an undetermined number of users from other platforms (in this case, the other platforms are Go (board game) servers):

A sketch of the form

My Zod schema looks like this at this point:

export const profileFormValidationSchema = z.object({
  go_users: z.record(
    z.string(),
    z.object({
      server: z.string().optional(),
      username: z.string().optional(),
      strength: z.string().optional(),
    }),
  ).optional(),
})

My form is a bit complicated, but the code is open-source and you can check it out here. Here is a simplified example of what I'm doing right now:

type ProfileFormProps = {
  initialValues?: ProfileFormValidation
}

export function ProfileForm({ initialValues }: ProfileFormProps) {
  const profileForm = useForm<ProfileFormValidation>({
    resolver: zodResolver(profileFormValidationSchema),
    defaultValues: initialValues,
  })

  const [totalUsers, setTotalUsers] = useState(
    Object.values(initialValues?.go_users ?? {}).length,
  )

  return (
    <>
      <h2 className="mt-6">Edite Seu Perfil</h2>

      <Form {...profileForm}>
        <form
          onSubmit={profileForm.handleSubmit(onSubmit)}
          className="flex flex-col gap-6"
        >
          <fieldset className="grid grid-cols-12 gap-x-2 gap-y-3 items-end">
            <legend className="ml-3 mb-2 text-lg font-bold col-span-2">
              3. Usuários em Servidores de Go
            </legend>

            {Array.from(Array(totalUsers + 1), (e, i) => {
              const key = `user${i}`
              return (
                <>
                  <FormItem className="col-span-3">
                    <FormLabel className="ml-3">
                      Servidor - {i}
                    </FormLabel>
                    <FormControl>
                      <Input
                        placeholder="OGS"
                        value={
                          profileForm.getValues(
                            "go_users",
                          )?.[key]?.server ?? ""
                        }
                        onChange={(e) => {
                          const currentUsers =
                            profileForm.getValues(
                              "go_users",
                            )
                          const newGoUsers = {
                            ...currentUsers,
                          }
                          newGoUsers[key] = {
                            ...currentUsers?.user1,
                            server: e.target.value,
                          }
                          profileForm.setValue(
                            "go_users",
                            newGoUsers,
                          )
                        }}
                      />
                    </FormControl>
                    <FormMessage />
                  </FormItem>
                  <FormItem className="col-span-5">
                    <FormLabel className="ml-3">
                      Nome
                    </FormLabel>
                    <FormControl>
                      <Input placeholder="usuário" />
                    </FormControl>
                    <FormMessage />
                  </FormItem>
                  <FormItem className="col-span-3">
                    <FormLabel className="ml-3">
                      Força
                    </FormLabel>
                    <FormControl>
                      <Input placeholder="10k" />
                    </FormControl>
                    <FormMessage />
                  </FormItem>
                  {i === totalUsers && (                    
                    <Button
                      className="col-span-1"
                      onClick={() =>
                        setTotalUsers(totalUsers + 1)
                      }
                    >
                      <Plus className="h-4 w-4" />
                    </Button>
                  )}
                </>
              )
            })}
          </fieldset>

          <div className="w-full flex justify-end">
            <Button className="w-max" type="submit">
              Salvar
            </Button>
          </div>
        </form>
      </Form>
    </>
  )
}

The input is not really controllable like this, and the result seems a bit unpredictable.

For nested objects, I was able to control things with Zod and React Hook Form in a much cleaner way than I expected, like this:

export const profileFormValidationSchema = z.object({
  socials_links: z
    .record(z.string(), z.string().url().optional())
    .optional(),
})

...

<FormField
  control={profileForm.control}
  name="socials_links.facebook"
  render={({ field }) => {
    return (
      <FormItem className="col-span-6">
        <FormLabel className="ml-3">
          Facebook
        </FormLabel>
        <FormControl>
          <Input
            type="url"
            placeholder="https://facebook.com/joao.silva"
            {...field}
          />
        </FormControl>
        <FormMessage />
      </FormItem>
    )
  }}
/>

But for creatable fields, I have no idea if there's a simple or clean way to do it. At any rate, those specific creatable fields don't have any fancy validation to them, so having them "manually" changed through form.setValue(...) should be enough, I believe.

Upvotes: 0

Views: 211

Answers (2)

Faiz Khatri
Faiz Khatri

Reputation: 51

You can use useFieldArray from React Hook Form to build this type of form.

Here's a video that will help you.

Edit

You can integrate Zod by creating a schema of array of objects. Here's a working example.

const schema = z.object({
  go_users: z.array(
    z.object({
      server: z.string(),
      username: z.string(),
      strength: z.string(),
    })
  ),
});

export default function App() {
  const { control, register, handleSubmit } = useForm<z.infer<typeof schema>>({
    resolver: zodResolver(schema),
    defaultValues: {
      go_users: [{ server: "", username: "", strength: "" }],
    },
  });
  const { fields, append } = useFieldArray({
    control,
    name: "go_users",
  });

  return (
    <div className="App">
      <form
        onSubmit={handleSubmit((data) => {
          console.log(data);
        })}
      >
        {fields.map((field, index) => (
          <div key={index}>
            <input
              placeholder="server"
              {...register(`go_users.${index}.server`)}
            />
            <input
              placeholder="username"
              {...register(`go_users.${index}.username`)}
            />
            <input
              placeholder="strength"
              {...register(`go_users.${index}.strength`)}
            />
            <br />
          </div>
        ))}
        <button
          type="button"
          onClick={() => {
            append({ server: "", username: "", strength: "" });
          }}
        >
          Add item
        </button>
        <button type="submit">Submit</button>
      </form>
    </div>
  );
}

Upvotes: 1

psygo
psygo

Reputation: 7583

As a workaround, I ended up creating a local state variable for keeping track of the inputs, and then using form.setValue(...) to sync React Hook Form to it.

export function ProfileForm({
  initialValues,
}: ProfileFormProps) {
  const profileForm = useForm<ProfileFormValidation>({
    resolver: zodResolver(profileFormValidationSchema),
    defaultValues: initialValues,
  })

  const [goUsers, setGoUsers] = useState<GoUsers>(
    initialValues?.go_users ?? {},
  )

  function totalUsers() {
    return Object.keys(goUsers ?? {}).length
  }

  return (
     <>
        {Array.from(Array(totalUsers() + 1), (e, i) => {
          const key = `user-${i}`
          return (
            <div
              key={i}
              className="grid grid-cols-12 gap-x-2 gap-y-3 items-end"
            >
              <FormItem className="col-span-3">
                <FormLabel className="ml-3">
                  Servidor
                </FormLabel>
                <FormControl>
                  <Input
                    placeholder="OGS"
                    value={goUsers?.[key]?.server ?? ""}
                    onChange={(e) => {
                      const currentUsers =
                        profileForm.getValues(
                          "go_users",
                        )
                      const newGoUsers = {
                        ...currentUsers,
                      }
                      newGoUsers[key] = {
                        ...currentUsers?.[key],
                        server: e.target.value,
                      }
                      profileForm.setValue(
                        "go_users",
                        newGoUsers,
                      )
                      setGoUsers(newGoUsers)
                    }}
                  />
                  
            ...
        }
    </>
  )

Upvotes: 0

Related Questions