<Demopeu/>

React-Hook-Form

React-Hook-Form

referencereactreact-hook-formformlibrary

이번 프로젝트에서 사용했던 React-Hook-Form에 대해 정리해보려고 한다.

📌 React-Hook-Form의 등장 배경

form을 관리할 때, useState를 사용하여 각 필드를 독립적으로 관리하면 미친듯한 불편함이 발생할 수 있다. 이러면 2가지로 리팩토링 하면 되는거 아니냐? 라고 할 수 있는데,

  1. 자식 컴포넌트로 분리하여 각각의 상태를 관리하는 방법
  2. form을 하나의 객체로 관리하는 방법

submit 시점에서 데이터를 수집하는게 번거롭고, Boilerplate 지옥을 경험함에 따라 복잡해진다. 여기서 현대에 제일 중요한 문제가 등장하는데 유효성 검사와 상태 추적이 힘들어 질 수 있다.

여기서 가장 치명적인 문제는 렌더링 성능 저하이다.
form의 입력 필드가 늘어날 수록 화면이 버벅이는 현상을 피할 수 없다. 그렇다면 React의 상태를 쓰지 않고 순수 HTML form처럼 DOM을 활용하면 되지 않을까? 이것을 비제어 컴포넌트라고 한다.

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

  • 제어 컴포넌트: React state를 "신뢰 가능한 단일 출처"로 만들어 React에 의해 값이 제어되는 엘리먼트
  • 비제어 컴포넌트: DOM이 폼 데이터를 직접 관리하는 방식

하지만 비제어 컴포넌트는 유효성 검사나 상태 추적이 어렵고, React의 상태 관리와 잘 어울리지 않는다. 실시간 상태 추적 및 에러 메시지 처리가 끔찍하게 어려워지기 때문이다.

React-Hook-Form(이하 RHF)은 정확히 이 딜레마를 해결하기 위해 등장했다.

내부적으로는 ref를 활용하여 비제어 컴포넌트 방식을 채택하고, 동시에 React의 상태 관리와 유효성 검사를 결합하여 렌더링 성능을 최적화하면서도 상태 추적과 에러 처리를 간편하게 만든다.

이번 프로젝트에서 나도 유효성을 클라이언트에서 검증하고, 상세한 에러 추적 및 상태 관리를 원했기에 RHF을 선택하게 되었다.

또 다른 RHF의 장점

  • 다른 라이브러리보다 속도가 빠름
  • 타입 안정성

📌 RHF의 핵심, useForm

useForm은 form의 전체 상태, 유효성 검사, 제출 등을 관리하는 핵심 함수이다. useForm을 호출하면 수많은 메서드를 반환하지만, 핵심만 살펴보겠다.

1. register: 입력창에 몰래 꽃는 빨대

순수 HTML 태그를 리렌더링 없이 추적하는 가장 기본적인 도구이다.

// 기존의 귀찮은 방식 (제어 컴포넌트)
<input name="email" value={email} onChange={(e) => setEmail(e.target.value)} />

// RHF 방식 (비제어 컴포넌트)
<input {...register("email", { required: "이메일은 필수입니다." })} />

이때, 스프레드 연산자(...)을 사용하여 RHF가 알아서 onChange, onBlur 등의 이벤트를 처리해준다.

2. handleSubmit: 편리한 제출 핸들러

폼을 제출할 때 브라우저의 기본 동작(새로고침)을 막기 위해 항상 쓰던 e.preventDefault()를 더 이상 쓸 필요가 없다.

handleSubmit은 에러가 단 하나라도 있으면 제출 함수를 아예 실행조차 시키지 않는다. 따라서 제출 함수 내부에서는 에러가 없다는 것을 보장할 수 있다.

3. formState: 폼의 상태 대시보드

formState는 폼의 상태를 관리하는 객체이다. 주로 에러 메시지, 제출 상태 등을 확인할 때 사용한다.

  • isSubmitting: 폼이 제출 중인지 여부
  • errors: 폼의 에러 메시지
  • isValid: 폼이 유효한지 여부
  • isDirty: 폼이 변경되었는지 여부

📌 렌더링 최적화의 핵심: watch vs useWatch

기본적으로 리렌더링을 막지만, 실시간으로 값을 추적하고 싶은 일(유효성 검사 등)이 생긴다. 이럴 때 사용하는 useForm의 메서드는 watchuseWatch이다.

1. watch: 전체 리렌더링 유발

watch는 특정 필드의 값을 실시간으로 읽어온다. 매우 편리하지만, 값이 변경될 때마다 전체 컴포넌트가 리렌더링된다. useState와 비슷한 개념이다.

가벼운 로그인 폼, 특정 값 선택 시 폼의 전체적인 레이아웃 구조가 바뀔 때 사용하는 게 좋다.

2. useWatch: 부분 리렌더링 유발

이 문제를 해결하기 위해 등장한 것이 useWatch이다. useWatch를 사용하면 값이 변할 때 해당 하위 컴포넌트 하나만 독립적으로 리렌더링 된다. 등장 배경 1번과 유사해 보이지만, 부모의 control 객체를 넘겨 받기 때문에 정교한 제어 및 제출 시점에 유리하다.

// ✅ 최상단 메인 폼 (리렌더링 안 됨!)
export default function Form() {
  const { control } = useForm();
  // ...
  return <NicknameMonitor control={control} />;
}

// ✅ 격리된 하위 컴포넌트 (여기만 리렌더링 됨!)
function NicknameMonitor({ control }) {
  const nickname = useWatch({ control, name: "nickname" });
  return <p>현재 글자 수: {nickname.length}</p>;
}

📌 커스텀 UI와의 결합: Controller vs useController

순수 HTML 태그에 RHF를 연결 할 때는 아무 문제 없지만, 실무에서는 외부 UI 라이브러리(Shadcn 등)를 사용한다.

문제는 이런 커스텀 컴포넌트들은 RHF와 호환되지 않는다. 따라서 RHF와 호환되도록 감싸주는 작업이 필요하다. ControlleruseController는 이러한 문제를 해결해주는 통역사 역할을 한다.

1. Controller

<Controller> 컴포넌트로 외부 UI 컴포넌트를 감싸면, RHF의 상태를 해당 컴포넌트의 입맛에 맞게(props 이름에 맞춰서) 주입할 수 있다.

<Controller
  name="customSelect"
  control={control} // 부모의 통제권 넘겨주기
  render={({ field }) => (
    // field 안에 value, onChange 등이 들어있음
    <MyFancySelect 
      selectedValue={field.value} 
      onSelectionChange={field.onChange} 
    />
  )}
/>

하지만, 입력칸이 많아지면 콜백 지옥이 펼쳐진다. 이럴 때를 위해 useController가 등장했다.

2. useController

useControllerController 컴포넌트의 로직을 훅으로 분리한 것이다. Controller와 동일한 역할을 하지만, 컴포넌트가 아닌 훅으로 사용되기 때문에 더 유연하게 사용할 수 있다.

import { useController } from "react-hook-form";

// 내부에서 통역(useController)을 알아서 끝내는 독립된 컴포넌트
export function FormInput({ name, control, label }) {
  const { field, fieldState } = useController({ name, control });

  return (
    <div>
      <label>{label}</label>
      <input {...field} className="my-custom-style" />
      {/* 에러 메시지도 여기서 바로 처리! */}
      {fieldState.error && <span className="text-red-500">{fieldState.error.message}</span>}
    </div>
  );
}

export default function CleanForm() {
  const { control, handleSubmit } = useForm();

  return (
    <form onSubmit={handleSubmit(onSubmit)}>
      {/* 렌더링 코드가 예술적으로 깔끔해진다 */}
      <FormInput name="email" control={control} label="이메일" />
      <FormInput name="password" control={control} label="비밀번호" />
      <button type="submit">제출</button>
    </form>
  );
}

📌 RHF 심화 기술: useFormContext, useFieldArray

단순 로그인 폼처럼 입력 칸이 적을 때는 위의 기술만으로 충분하다. 하지만, 수십 개의 거대한 폼을 만들 때가 있다. 이때 form의 복잡도를 낮춰주는 좋은 hook이 있다.

1. useFormContext: Props Drilling 지옥 탈출

ReactContext API와 유사한 방식으로, 모든 권한을 가진 객체를 Provider로 감싸서 공급한다.

  • [최상단 부모 컴포넌트]
    최상단에서 useForm으로 모든 권한을 가진 객체(methods)를 만들고, <FormProvider>로 전체를 감싸서 공급해 버린다.
import { useForm, FormProvider } from "react-hook-form";

export default function ComplexForm() {
  const methods = useForm();

  return (
    // 하위의 모든 컴포넌트가 폼 상태에 접근할 수 있게 됨
    <FormProvider {...methods}>
      <form onSubmit={methods.handleSubmit(onSubmit)}>
        {/* 저 밑에 숨어있는 깊은 컴포넌트라고 가정 */}
        <DeepChildInput />
        <button type="submit">제출</button>
      </form>
    </FormProvider>
  );
}
  • [깊은 곳에 있는 자식 컴포넌트]
    이제 부모로부터 귀찮게 props를 받을 필요가 없다. useFormContext 한 줄이면 통제실에 바로 접근할 수 있다.
import { useFormContext } from "react-hook-form";

export function DeepChildInput() {
  // FormProvider가 뿌려준 무기들을 여기서 바로 꺼내 씀!
  const { register, formState: { errors } } = useFormContext();

  return (
    <div>
      <input {...register("deepField")} />
      {errors.deepField && <span>에러 발생!</span>}
    </div>
  );
}

2. useFieldArray: 동적 리스트 관리

실무에서 짜증나는 요구사항 중 하나는 입력 칸이 무한히 늘어나는 경우다. 이 때 빛을 발하는 것이 useFieldArray다.

3가지 주요 메서드:

  • fields: 현재 배열의 모든 항목을 배열로 반환(화면에 그리기 위해 map으로 순회할 때는 일반 배열이 아니므로 꼭 이거 사용)
  • append: 배열에 새로운 항목을 추가
  • remove: 배열에서 특정 인덱스의 항목을 제거(RHF가 알아서 인덱스 재정렬까지 해줌)
import { useForm, useFieldArray } from "react-hook-form";

export default function ResumeForm() {
  const { register, control } = useForm({
    defaultValues: { skills: [{ name: "" }] } // 초기값 (1칸)
  });

  // 배열 상태를 완벽하게 제어해 주는 훅
  const { fields, append, remove } = useFieldArray({
    control,
    name: "skills",
  });

  return (
    <form>
      {/* fields 배열을 map으로 돌림 */}
      {fields.map((field, index) => (
        <div key={field.id}>
          {/* 동적 인덱스 할당이 핵심! */}
          <input {...register(`skills.${index}.name`)} />
          <button type="button" onClick={() => remove(index)}>삭제</button>
        </div>
      ))}
      
      {/* 빈 객체를 배열 맨 뒤에 밀어 넣음 (입력 칸 추가) */}
      <button type="button" onClick={() => append({ name: "" })}>
        + 기술 추가
      </button>
    </form>
  );
}