본문 바로가기
Frontend/React

[React] react-hook-form 과 zod로 리액트 form 리팩토링하기

by yerin.dev 2024. 2. 13.

React와 form

 

프론트엔드 개발에서 빠질 수 없는 게 바로 form(+input) 다루기 아닐까 싶다. 어느 사이트에나 회원가입, 로그인 등의 form은 반드시 존재한다. 현재 진행중인 나의 Bookshelf 프로젝트도 마찬가지!


기존의 코드는 사용자가 input에 입력을 하면, onChange에 바인딩된 이벤트 핸들러가 동작하여 입력한 값을 setState()를 통해 email과 password 라는 state에 각각 저장하고, submit을 하면 firebase auth로 회원가입이 진행되는 방식의 코드였다. (이런 방식을 제어 컴포넌트라고 한다.)


여기에 validation이 필요한데 기본적인 form의 기능을 사용해도 validation을 구현할 수 있기는 하다. 하지만 만약 input이 2-3개가 아니라 엄청나게 많아진다면? 일일이 validation을 하고, 각각의 상태를 관리하는 것은 꽤나 번거로운 일일 것이다. 그래서 이참에 진행하고 있는 Bookshelf 프로젝트에 react-hook-form과 zod를 도입해서 이용해보기로 했다.

 

 

제어 컴포넌트와 비제어 컴포넌트

 

  • 제어 컴포넌트 form : 리액트에 의해서 실시간으로 상태가 제어된다. 따라서 사용자의 매 입력마다 상태가 바뀌고 렌더링이 일어나게 된다. 사용자의 실시간 입력을 추적할 수 있다는 장점이 있다. 하지만 불필요한 리렌더링이 일어나게 된다는 단점도 존재한다. 리액트 공식 문서에서는 모든 폼을 제어 컴포넌트로 사용하기를 권하고 있다.
  • 비제어 컴포넌트 form : 리액트가 사용자의 입력값을 제어하지 않는다. 따라서 실시간 입력을 알 수는 없다. 하지만 submit을 하는 순간 값을 알 수 있다. (ref 사용)

 

결론 : 관리해야 하는 input의 개수가 적을 때는 제어 컴포넌트도 큰 문제는 없을 것이다. 다만, input의 개수가 늘어나면 그만큼 state도 늘어나고 불필요한 리렌더링이 과하게 많이 발생하게 된다.

게다가 각각의 input 값에 대한 유효성 검사까지 해야한다면 코드 양도 그만큼 많아지고, 전체적으로 유지보수하기 힘들어진다.

 

먼저 제어 컴포넌트를 활용한 기존의 코드다.

 

 

export default function SignUpPage() {
  const [email, setEmail] = useState("");
  const [password, setPassword] = useState("");

  const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
    if (e.target.name === "email") setEmail(e.target.value);
    else if (e.target.name === "password") setPassword(e.target.value);
  };

  const handleClick = (e: React.MouseEvent<HTMLInputElement>) => {
    const { name } = e.target as HTMLInputElement;
    if (name === "email") setEmail("");
    else if (name === "password") setPassword("");
  };

  const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
    try {
      e.preventDefault();

      const data = await signUpWithEmailAndPassword(email, password);

      if (data) {
        await setDoc(doc(db, "users", data.uid), {
          userName: data?.displayName || data?.uid,
          uid: data?.uid,
        });
      }
    } catch (e) {
      console.error(e);
    }
  };

  return (
    <section>
      <h1>Sign Up</h1>

      <form onSubmit={handleSubmit}>
        <input
          name="email"
          type="email"
          placeholder="Email"
          value={email}
          required
          onChange={handleInputChange}
          onClick={handleClick}
        />
        <input
          name="password"
          type="password"
          placeholder="Password"
          value={password}
          required
          onChange={handleInputChange}
        />
        <input
          name="confirmPassword"
          type="password"
          placeholder="Confirm Password"
          value={password}
          required
          onChange={handleInputChange}
        />
        <button type="submit">
          회원 가입하기
        </button>
        <button
          type="submit"
        >
          로그인 하러가기
        </button>
      </form>
    </section>
  );
}

 

 

여기에 react-hook-form을 적용해서 리팩토링해보자!
그 전에, react-hook-form이란?

 

 

react-hook-form

 

리액트 훅 폼을 사용해야 하는 이유?


공식 문서에 FAQ를 읽어봤다.

 

 

Performance of React Hook Form

 

Performance is one of the primary reasons why this library was created. React Hook Form relies on an uncontrolled form, which is the reason why the register function captures ref and the controlled component has its re-rendering scope with Controller or useController. This approach reduces the amount of re-rendering that occurs due to a user typing in an input or other form values changing at the root of your form or applications. Components mount to the page faster than controlled components because they have less overhead. As a reference, there is a quick comparison test that you can refer to at this repo link.

 

비제어 컴포넌트를 활용하여 리렌더링 최소화. 리액트 훅 폼의 register 함수는 ref를 캡쳐하여 입력값을 받아온다. 제어 컴포넌트는 사용자의 타이핑마다 리렌더링이 일어나지만 이 방식은 리렌더링이 적어서 제어 컴포넌트보다 페이지에 마운트 되는 속도가 빠르다. 이 repo에서 속도를 확인할 수 있다고 한다. 다른 라이브러리인 Formik보다도 빠른 속도임을 확인할 수 있다.

 

 

react-hook-form 적용 후

 

import { useForm, FieldValues } from "react-hook-form";

export default function SignUpPage() {
  const {
    register, // 각각의 인풋에 props로 전달하여 등록
    handleSubmit, // handleSubmit에 submit시 호출해야 할 함수를 전달
    formState: { errors, isSubmitting },
    reset, // 인풋 리셋하기
    getValues, // pw와 confirm pw 비교할 때 필요
  } = useForm();

  const onSubmit = async (data: FieldValues) => {
    // 코드 생략
  };

  return (
    <section>
      <h1>Sign Up</h1>

      <form onSubmit={handleSubmit(onSubmit)}>
        {errors.email && (
          <p className="text-red-500">{`${errors.email.message}`}</p>
        )}
        {errors.password && (
          <p className="text-red-500">{`${errors.password.message}`}</p>
        )}
        {errors.confirmPassword && (
          <p className="text-red-500">{`${errors.confirmPassword.message}`}</p>
        )}
        <input
          {...register("email", { required: "Email is required" })}
          type="email"
          placeholder="Email"
        />
        <input
          {...register("password", {
            required: "Password is required",
            minLength: {
              value: 10,
              message: "Password must be at least 10 characters",
            },
          })}
          type="password"
          placeholder="Password"
        />
        <input
          {...register("confirmPassword", {
            required: "Please confirm the password",
            validate: (value) =>
              value === getValues("password") || "Passwords must match.",
          })}
          type="password"
          placeholder="Confirm Password"
        />
        <button
          type="submit"
          disabled={isSubmitting}
        >
          회원 가입하기
        </button>
        <button
          type="submit"
        >
          로그인 하러가기
        </button>
      </form>
    </section>
  );
}

 

 

코드가 간결해졌다.

 

 

        <input
          {...register("confirmPassword", {
            required: "Please confirm the password",
            validate: (value) =>
              value === getValues("password") || "Passwords must match.",
          })}
          type="password"
          placeholder="Confirm Password"
        />

 

 

유효성 검사도 react-hook-form 만으로도 가능하지만 zod를 사용할 수도 있다.

 

 

zod

 

zod는 타입스크립트 우선의 스키마 선언, 유효성 검사 라이브러리다.
타입스크립트는 런타임 타입 에러만 잡을 수 있고 zod는 컴파일 시점의 타입 에러를 잡을 수 있다. 또한 데이터를 더 섬세하게(숫자 범위 설정 등) 다룰 수 있다.
zod에 대해서 잘 정리된 내용은 이 블로그 에서 확인할 수 있다.

 

 

 

react hook form + zod로 코드 리팩토링

 

npm i zod @hookform/resolvers // zod와 rhf 함께 사용

 

// types.ts
export const signUpSchema = z
  .object({
    email: z.string().email(),
    password: z.string().min(8, "비밀번호는 최소한 8글자여야 합니다."),
    confirmPassword: z.string(),
  })
  .refine((data) => data.password === data.confirmPassword, {
    message: "입력한 비밀번호와 다릅니다.",
    path: ["confirmPassword"],
  });

export type TSignUpSchema = z.infer<typeof signUpSchema>;
// zod의 infer를 통해 쉽게 타입을 정의할 수도 있다.

 

// SignUpPage.tsx

const {
    register,
    handleSubmit,
    formState: { errors, isSubmitting },
    reset,
  } = useForm<TSignUpSchema>({ resolver: zodResolver(signUpSchema) });

export default function SignUpPage() {
    return (
            // 나머지 코드 생략
          <input
          {...register("email")}
          type="email"
          placeholder="Email"
          className="input w-[320px] pl-3 py-2 rounded-md"
        />
    )}