devvoyage

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

프론트엔드를 하다 보면 폼은 반드시 한 번은 만들게 됩니다. 처음에는 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,
  });
}

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

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

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

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

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

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>
  ),
);

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

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

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

규칙은 스키마 한 곳에

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'],
  });

마지막의 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>
);

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