form 개발 (react-hook-form & zod)

기존에는 useInput이라는 커스텀 훅을 통해서 여러 input의 상태를 object 타입으로 관리하고, useInput에 callback함수를 받아서 각 input별로 유효성 검사를 했었습니다 하지만 이런 방식은 폼의 단계가 늘어날수록 코드의 복잡도가 높아지고, 유효성 검사를 위해서 정규표현식을 사용하게 되면 가독성이 떨어지게 되었습니다.

그래서 react-hook-formzod를 사용해서 form을 개발하여 보다 직관적으로 유효성 검사나 상태를 관리하고자 했습니다.

제가 폼을 개발을 하면서 해결하고 싶은 문제점은 크게 2가지가 있었습니다.

  1. 여러 단계의 폼의 상태관리
  2. 유효성 검사

기존에는 정규표현식을 사용해서 유효성 검사를 했었는데, 정규표현식을 사용하게되면 해당 코드의 가독성이 떨어지게 되었었습니다.

또한 input의 상태를 관리하기 위해서 커스텀훅을 개발하여 사용했었는데, 폼의 단계가 늘어나고 변동 될수록 해당 코드의 복잡도가 높아져 유지보수에 대한 문제가 많이 생겼습니다.

react-hook-formzod를 통해서 코드 컨벤션을 해결하고 보다 직관적으로 유효성 검사나 상태를 관리하여 개발을 하고자 했습니다.

react-hook-form

react-hook-form에서는 useForm을 통해서 form을 만들고, useForm을 통해서 register를 통해서 input을 등록하고, handleSubmit을 통해서 form을 제출할 수 있습니다.

form.tsx
import { useForm } from 'react-hook-form';

const { register, handleSubmit } = useForm();

const onSubmit = (data) => {
  console.log(data);
};
return (
  <form onSubmit={handleSubmit(onSubmit)}>
    <input {...register('name')} />
    <input {...register('age')} />
    <button type="submit">submit</button>
  </form>
)

버튼을 클릭하게 되면 onSubmit함수가 실행되고, 해당 함수에서는 register에 props로 넘긴 'string'값의 명칭대로 data를 가져올 수 있습니다.

{
  "name": "name",
  "age": "age"
}

그리고 register에 간단한 유효성 검사를 할 수 있습니다

form.tsx
  ...

  const {
    register,
    formState: { errors },
    handleSubmit,
  } = useForm();

  return (
    <form onSubmit={handleSubmit(onSubmit)}>
        <label>Name</label>
        <input
          {...register("name", {
            required: "이름을 입력해주세요",
            maxLength: 20,
          })}
        />
        {errors.name && <p className="error">{errors.name.message}</p>}
      <button type="submit">submit</button>
    </form>
  );

위와 같이 'name' 값은 항상 값을 입력하게 하고, 20자 이상은 입력하지 못하게 할 수 있습니다. formSate의 errors를 통해서 각 input의 에러메세지를 출력할 수 있습니다.

react-hook-form만으로도 훌룡하게 처리를 할 수 있지만 zod를 통해서 유효성 검사를 더 관리가 유용해집니다.


zod

zod라이브러리는 타입스크립트를 위한 스키마 검증 라이브러리입니다.

form.tsx
const schema = z
  .object({
    email: z.string().email("이메일 형식이 아닙니다"),
    password: z
      .string()
      .min(8, "비밀번호는 8글자 이상이어야 합니다")
      .max(15, "비밀번호는 최대 15글자까지 가능합니다")
      .regex(
        /^(?=.*[a-zA-Z])(?=.*[!@#$%^*+=-])(?=.*[0-9]).{8,15}$/,
        "영문+숫자+특수문자(! @ # $ % & * ?) 조합으로 8~15자리를 사용해야 합니다"
      ),
  })

type FormData = z.infer<typeof schema>;

zod로 스키마를 만든뒤 타입을 정의해주면, 해당 타입으로 form의 데이터를 관리할 수 있습니다. 그리고 이 스키마의 유효성 검사를 react-hook-form에서 사용할 수 있습니다. zod를 react-hook-form에서 사용하기 위해서는 @hookform/resolvers를 설치해야합니다.

const { register, handleSubmit } = useForm<FormData>({
  resolver: zodResolver(schema),
});

<form onSubmit={handleSubmit(onSubmit)}>
  <div className="flex flex-col gap-2">
    <label>Email</label>
    <input {...register("email")} />
    {errors.email && <p className="error">{errors.email.message}</p>}
  </div>
  <div className="flex flex-col gap-2">
    <label>Password</label>
    <input {...register("password")} />
  </div>
  {errors.password && <p className="error">{errors.password.message}</p>}
  <button type="submit">submit</button>
</form>;

기존의 useForm에 위와 같이 zodResolver를 넘겨주면, zod의 스키마를 통해서 유효성 검사를 할 수 있습니다.

회원가입 폼을 만든다고 했을대에는 zod 스키마를 만들때 .refine을 통해서 password와 passwordConfirm이 일치하는지 확인하는 로직을 추가할 수 있습니다.

form.tsx
const schema = z
  .object({
    email: z.string().email("이메일 형식이 아닙니다"),
    password: z
      .string()
      .min(8, "비밀번호는 8글자 이상이어야 합니다")
      .max(15, "비밀번호는 최대 15글자까지 가능합니다")
      .regex(
        /^(?=.*[a-zA-Z])(?=.*[!@#$%^*+=-])(?=.*[0-9]).{8,15}$/,
        "영문+숫자+특수문자(! @ # $ % & * ?) 조합으로 8~15자리를 사용해야 합니다"
      ),
    passwordConfirm: z.string().min(8, "비밀번호를 다시 입력해주세요"),
  }).refine((data) => data.password === data.passwordConfirm, {
    path: ["passwordConfirm"],
    message: "비밀번호가 일치하지 않습니다.",
  });

type FormData = z.infer<typeof schema>;

위와 같이 정의할 경우 password와 passwordConfirm이 일치하지 않을 경우 에러메세지를 출력할 수 있습니다.

활용

  1. 유효성 검사 시기 옵션

useForm에 mode옵션으로 유효성 검사를 하는 시기를 정할 수 있습니다. 기본적으로 저는 입력하고 있을시에는 유효성 검사를 하지 않기를 원했기 때문에 onBlur을 사용했습니다.

form.tsx
const {
  register,
  handleSubmit,
} = useForm({
  mode: "onBlur",
});

mode 옵션으로 다음을 지원합니다


  1. Controller

개발을 하다보면 커스텀 컴포넌트르 개발을 할때 useForm의 register를 사용하기 어려울때가 있습니다. 이럴때 Controller를 사용하면 커스텀 컴포넌트를 사용할 수 있습니다.

예를 들어 커스텀 Select 컴포넌트를 사용할때 useForm의 register를 바로 적용을 못할때 아래와 같이 Controller를 통해서 사용할 수 있습니다.

form.tsx
import { Controller } from "react-hook-form";
const {
    register,
    formState: { errors },
    handleSubmit,
    control,
  } = useForm({  resolver: zodResolver(registerSchema) });
  return <>
    <Controller
      control={control}
      name="career"
      render={({ field }) => (
        <Select
          label="경력 기간"
          placeholder="경력"
          onChange={field.onChange}
          error={errors.career}
        />
      )}
    />
  </>

  1. 단계별 폼

한페이지에 여러데이터를 입력하는 UI보다 단계별로 입력하는 페이지가 더 직관적이고 사용자에게 더 좋은 UX를 제공할 수 있습니다.

여러 단계의 폼을 만들때 TOSS의 SLASH23의 funnel를 활용하여 useFunnel훅을 통해서 개발을 했습니다.

그렇게 되면 마지막 단계에서 submit을 하게되고 그전 단계의 버튼에서 다음 단계로 넘어가는 버튼을 클릭할때 유효성 검사를 한 뒤에 넘어가도록 개발을 해야합니다.

그럴때 useFormtrigger를 통해서 유효성 검사가 완료 된뒤에 다음 단계로 넘어가도록 할 수 있습니다.

form.tsx
const { Funnel, step, setStep } = useFunnel(['email', 'password'], 'email');
const {
    register,
    formState: { errors },
    trigger,
    handleSubmit,
  } = useForm<FormData>({ mode: 'onBlur', resolver: zodResolver(registerSchema) });

const next = (step: string) => {
    if (step === 'password') {
      trigger('email').then(validation => {
        if (validation) setStep('password');
      });
    }
  }

return (
  <form onSubmit={handleSubmit(onSubmit)}>
    <Funnel>
      <Funnel.Step name="email">
        <LabelInput
          className="w-full"
          label="이메일"
          inputProps={{
            ...register("email"),
            name: "email",
            placeholder: "이메일을 입력해주세요",
          }}
          error={errors.email}
        />

        <Button
          type="button"
          onClick={() => next("password")}
        >
          다음
        </Button>
      </Funnel.Step>
      <Funnel.Step name="password">
        <LabelInput
          label="비밀번호"
          inputProps={{
            ...register("password"),
            name: "password",
            type: "password",
            placeholder: "비밀번호를 입력해주세요",
          }}
          error={errors.password}
        />
        <LabelInput
          label="비밀번호 확인"
          inputProps={{
            ...register("passwordConfirm"),
            name: "passwordConfirm",
            type: "password",
            placeholder: "비밀번호 확인을 입력해주세요",
          }}
          error={errors.passwordConfirm}
        />
        <Button type="submit">
          가입
        </Button>
      </Funnel.Step>
    </Funnel>
  </form>
);

useFunnel은 현재 step의 값에 따라서 화면에 보여지는 컴포넌트를 변경해주는 커스텀 훅입니다. 34번줄의 버튼을 클릭했을때 11번줄의 trigger를 통해서 유효성 검사를 한뒤에만 다음 단계로 넘어가도록 개발을 할 수 있습니다.

마치며

react-hook-form의 여러 옵션들은 다양한 상황에 맞게 사용할 수 있고, zod를 통해서 유효성 검사를 할때는 정규표현식을 사용하지 않아도 되어서 가독성이 높아졌습니다.

덕분에 기존의 방식보다 훨씬 직관적으로 유효성 검사나 상태를 관리할 수 있게 되었고, 코드의 복잡도가 낮아져서 유지보수에도 편리해졌습니다.