zod, react-hook-form 리뷰

2025.02.15
thumbnail for zod, react-hook-form 리뷰

프로젝트 내 기존의 입력 폼을 리팩토링하면서 다시 한번 zod, react hook form에 대해 리뷰해보고 싶어졌다.

zod, react hook form 사용 전

이메일 폼 하나에 대해서만 해도 이메일의 값, 유효성 상태, 유효성 검사 처리 등 작성해야 할 코드가 굉장히 많아진다. 또한 폼이 늘어날 수록 관리해야 할 상태도 많아지고 폼 입력 시마다 컴포넌트 전체가 리렌더링 되기 때문에 성능 저하를 가져 올 가능성이 있다.

위의 문제들을 zod와 react hook form을 이용해 리팩토링 해보려고 한다.

// email 폼 상태관리
const [email, setEmail] = useState("");
const [emailIsValid, setEmailIsValid] = useState({
  message: "email is required",
  isError: true,
});
 
// 이메일 유효성 검사 처리
const checkEmailVerification = (value: string) => {
  if (value == "") {
    setEmailIsValid({ isError: true, message: "email is required" });
    return;
  }
  setEmailIsValid({ isError: false, message: "" });
};
 
const emailOnChange = (value: string) => {
setEmail(value);
checkEmailVerification(value);
};
 
// 폼 유효성 상태
const formIsValid = !emailIsValid.isError && 그 외 다른 폼 에러 상태 ...
 
(생략)
 
<input
    name="email"
    type="text"
    value={email}
    onChange={(e) => emailOnChange(e.target.value)}
/>
 
<button disabled={!formIsValid}>Submit</button>

zod, react hook form 사용하기

1. zod 왜 사용하나?

  • 유효성 검사를 쉽게 처리 할 수 있다.
  • 타입스크립트만으로는 런타임 타입 검증을 할 수 없다.

2. react hook form 왜 사용하나?

  • 폼과 관련된 코드를 심플하게 작성 할 수 있다.
  • 제어 컴포넌트와 비제어 컴포넌트의 장점만을 가져와 사용 할 수 있다.

✅ 제어 컴포넌트 vs 비제어 컴포넌트

제어 컴포넌트 : state를 사용하여 데이터를 관리한다, 입력 폼이 많아질 수록 관리해야할 state도 많아지고 폼 입력값이 변할 떄마다 리렌더링 발생한다. 이 과정에서 불필요한 연산이 발생할 수 있다.

  • 언제 제어 컴포넌트 사용하면 좋은가? : 폼 입력 값이 변할 때마다 유효성 검사가 필요한 경우

비제어 컴포넌트 : state를 가지지 않으며 사용자 입력에 대한 DOM 이벤트를 직접 처리하여 데이터를 추출한다.

  • 언제 비제어 컴포넌트 사용하면 좋은가? : 성능 최적화가 중요한 경우, 외부 라이브러리와 통합 시(ex: shadcn ui)

zod, react hook form 사용해서 리팩토링하기

import { useForm, SubmitHandler } from "react-hook-form";
import { z } from "zod";
import { zodResolver } from "@hookform/resolvers/zod";
 
const loginSchema = z
  .object({
    email: z.string().nonempty({ message: "Email is required" }).email(),
    password: z.string().nonempty({ message: "Password is required" }),
    passwordConfirm: z.string().nonempty({ message: "Check your password" }),
  })
  .refine((data) => data.password === data.passwordConfirm, {
    path: ["passwordConfirm"],
    message: "Check your password",
  });
 
type LoginProp = z.infer<typeof loginSchema>;
 
export default function LoginPage() {
  const {
    register,
    handleSubmit,
    formState: { errors },
  } = useForm<LoginProp>({
    resolver: zodResolver(loginSchema),
  });
 
  const onSubmit: SubmitHandler<LoginProp> = (data) => 로그인 데이터 처리;
 
  return (
    <div>
      <form onSubmit={handleSubmit(onSubmit)}>
        <label htmlFor="email">e-mail</label>
        <input
          id="email"
          {...register("email", { required: true })}
        />
        {errors.email?.message && (
          <span>
            {errors.email?.message}
          </span>
        )}
 
        <label htmlFor="password">password</label>
        <input
          id="password"
          type="password"
          {...register("password")}
        />
 
        {errors.password?.message && (
          <span>
            {errors.password?.message}
          </span>
        )}
 
        <label>password confirm</label>
        <input
          id="passwordConfirm"
          type="password"
          {...register("passwordConfirm")}
        />
 
        {errors.passwordConfirm?.message && (
          <span>
            {errors.passwordConfirm?.message}
          </span>
        )}
 
        <buttontype="submit">Submit</button>
      </form>
    </div>
  );
}
 

+ input 컴포넌트화 하기

1. register 방식

  • 비교적 간단한 폼에서 불필요한 상태 관리 최소화해 최적화를 최대한 활용하고 싶을 때
// Input.tsx
type InputProps = {
  id: "email" | "password" | "passwordConfirm";
  type?: string;
  register: UseFormRegister<LoginProp>;
  errorMessage?: string;
};
 
export const Input = ({
  id,
  type = "text",
  register,
  errorMessage,
}: InputProps) => {
  return (
    <div>
      <input
        id={id}
        type={type}
        {...register(id)}
      />
      {errorMessage && (
        <span>{errorMessage}</span>
      )}
    </div>
  );
};
 
// LoginPage.tsx
<Input
    id="password"
    type="password"
    register={register}
    errorMessage={errors.password?.message}
/>

2. useController 방식

  • 커스텀 컴포넌트를 사용하거나 복잡한 폼 상태 관리 할 때
 
// Input.tsx
import React from "react";
import { useController, Control, FieldValues, Path } from "react-hook-form";
 
type InputProps<T extends FieldValues> = {
  name: Path<T>;
  control: Control<T>;
  type?: string;
  label?: string;
};
 
export const Input = <T extends FieldValues>({
  name,
  control,
  type = "text",
  label,
}: InputProps<T>) => {
  const {
    field,
    fieldState: { error },
  } = useController<T>({
    name,
    control,
    rules: { required: `${label || name} is required` },
  });
 
  return (
    <div>
      {label && (
        <label htmlFor={name}>{label}</label>
        )}
      <input
        id={name}
        type={type}
        {...field}
      />
      {error && (
        <span">{error.message}</span>
      )}
    </div>
  );
};
 
// LoginPage.tsx
const { control, handleSubmit } = useForm<LoginProp>({
    ...
});
 
<Input
    name="password"
    type="password"
    label="Password"
    control={control}
/>