본문으로 건너뛰기
devvoyage

React Hook Form & Zod으로 유연하게 폼 관리하기

4분 분량
목차

프론트엔드를 하다 보면 폼은 반드시 한 번은 만들게 됩니다. 처음에는 useState로 값과 에러를 들고 검증 로직을 직접 붙여 만들었는데, 필드가 늘어날수록 상태 선언과 조건문이 같이 늘어났습니다. 기획이 조금 바뀌면 고쳐야 할 곳이 여기저기 흩어져 있었습니다.

React Hook Form(이하 RHF)과 zod로 옮기면서 정리된 건 두 가지입니다. 입력값은 DOM이 들고 있게 하고, 검증 규칙은 스키마 한 곳에 모읍니다.

역할
React Hook Form비제어 입력 등록, 폼 상태 추적, 제출 처리
zod타입 스키마 선언과 런타임 검증
@hookform/resolvers둘을 이어주는 어댑터

RHF은 입력값을 DOM에서 관리하는 비제어 방식이 기본이라 불필요한 리렌더가 적습니다. 외부 UI 라이브러리처럼 비제어로 다루기 어려운 경우를 위한 제어 방식도 함께 제공합니다.

useForm

useForm이 폼 하나를 통째로 관장합니다. 제네릭으로 폼 값의 타입을 넘기면 이후 모든 메서드가 그 타입을 따라갑니다.

export interface SignUpFormData {
  email: string;
  password: string;
  confirmPassword: string;
  firstName: string;
  lastName: string;
  phoneNumber: string;
  consentCheckbox: boolean;
}
 
function ReactHookFormWithZodExample() {
  const {
    register,
    unregister,
    formState,
    watch,
    handleSubmit,
    reset,
    resetField,
    setError,
    clearErrors,
    setValue,
    setFocus,
    getValues,
    getFieldState,
    trigger,
    control,
  } = useForm<SignUpFormData>({
    mode: 'onChange',
    resolver: zodResolver(Schema),
    defaultValues: undefined,
  });
}
tsx

옵션은 열 가지가 넘지만 실제로 자주 만진 건 셋입니다.

mode는 제출 전에 언제 검증할지를 정합니다. 기본값 onSubmit 외에 onChange, onBlur, onTouched, all을 쓸 수 있습니다.

resolver는 외부 검증 라이브러리를 연결하는 자리입니다. @hookform/resolvers/zod의 zodResolver에 미리 만들어 둔 스키마를 넘기면 됩니다.

defaultValues는 초기값입니다. 회원가입에는 없어도 그만이지만 회원정보 수정처럼 기존 데이터를 채워야 하는 폼에서는 필수입니다. 그 값을 API로 받아온다면 defaultValues 대신 values를 쓰는 편이 낫습니다. 응답이 도착한 시점에 폼이 알아서 갱신됩니다.

function App() {
  const values = useFetch('/api');
  useForm({
    defaultValues: {
      firstName: '',
      lastName: '',
    },
    values, // will get updated once values returns
  });
}
tsx

register가 하는 일

입력 필드는 register가 돌려주는 것들을 펼쳐 넣기만 하면 등록됩니다. 스타일이 반복돼서 TextField로 한 번 감쌌습니다.

<TextField
  label="Email"
  required
  isError={!!formState.errors.email}
  errorMessage={formState.errors.email?.message}
  type="email"
  placeholder="Email"
  {...register('email')}
/>;
 
const TextField = React.forwardRef(
  (
    { label, required, isError, errorMessage, ...rest }: TextFieldProps,
    ref: React.ForwardedRef<HTMLInputElement>,
  ) => (
    <div className="flex w-full flex-col gap-1">
      <label htmlFor={rest.name}>
        {label}
        {required ? <strong className="pl-1 text-red-700">*</strong> : <></>}
      </label>
      <input
        id={rest.name}
        {...rest}
        className={FormatLib.cn(
          isError ? 'border-[1px] border-solid border-red-700' : '',
          'rounded-sm p-[10px]',
        )}
      />
      {isError ? (
        <span className="text-xs text-red-700">{errorMessage}</span>
      ) : (
        <></>
      )}
    </div>
  ),
);
tsx

펼쳐 넣은 값은 네 개입니다.

const { onChange, onBlur, name, ref } = register('email');
tsx

핵심은 ref와 name입니다. ref로 DOM의 입력 엘리먼트를 직접 잡아 비제어로 다루고, name이 에러 조회나 필드 초기화의 키가 됩니다. onChange와 onBlur는 기획이나 디자인 요구사항이 있을 때만 따로 꺼내 썼습니다.

규칙은 스키마 한 곳에

zod 스키마는 타입 선언처럼 읽히면서 검증 규칙까지 같이 담습니다. 메서드 체이닝이라 조건이 붙어도 흐름이 끊기지 않고, 무엇보다 기획이 바뀌었을 때 고칠 곳이 한 파일입니다.

const Schema = z
  .object({
    email: z.string().email({ message: 'Invalid email.' }),
    password: z
      .string()
      .min(8, { message: 'Must be 8 or more characters long.' })
      .max(16, { message: 'Must be 16 or fewer characters long.' })
      .regex(
        /^(?=.*[a-zA-Z])(?=.*\d)(?=.*[!@#$%^&*()_+{}\[\]:;<>,.?\/~`-]).*$/,
        { message: 'Invalid password.' },
      ),
    confirmPassword: z
      .string()
      .min(8, { message: 'Must be 8 or more characters long.' })
      .max(16, { message: 'Must be 16 or fewer characters long.' })
      .regex(
        /^(?=.*[a-zA-Z])(?=.*\d)(?=.*[!@#$%^&*()_+{}\[\]:;<>,.?\/~`-]).*$/,
        { message: 'Invalid password.' },
      ),
    firstName: z.string().min(1),
    lastName: z.string().min(1),
    phoneNumber: z.string(),
    consentCheckbox: z.boolean(),
  })
  .refine((data) => data.password === data.confirmPassword, {
    message: "Passwords don't match",
    path: ['confirmPassword'],
  });
tsx

마지막의 refine은 커스텀 검증을 붙이는 자리입니다. 단일 필드로는 판단할 수 없는 규칙, 여기서는 비밀번호와 확인란이 일치하는지를 여기서 봅니다. path로 어느 필드에 에러를 달지 지정할 수 있습니다.

제출 버튼은 언제 열리는가

필수 입력이 안 찼거나 검증에 걸리면 제출 버튼이 잠겨 있어야 합니다. formState의 두 값이면 충분합니다.

isDirty는 사용자가 입력을 한 번이라도 건드리면 true가 되고, isValid는 폼에 에러가 하나도 없을 때 true가 됩니다. 둘 다 만족할 때만 버튼을 엽니다.

const isButtonDisabled = !formState.isDirty || !formState.isValid;
 
return (
  <button
    form="signUpForm"
    disabled={isButtonDisabled}
    className="w-full rounded-xl bg-blue-400 py-[10px] text-white transition-colors hover:bg-blue-600 disabled:cursor-not-allowed disabled:bg-gray-400"
  >
    Submit
  </button>
);
tsx

이 정도가 폼 하나를 만드는 최소 구성입니다. 저는 여기까지를 보일러플레이트로 두고 요구사항에 따라 확장하는 식으로 씁니다. 외부 UI 라이브러리를 제어 컴포넌트로 붙일 때는 Controller, 항목이 늘었다 줄었다 하는 폼에는 useFieldArray가 있습니다. zod는 폼 밖에서도 쓸모가 있어서, API 응답을 파싱해 타입을 보장하는 데도 잘 맞았습니다.