본문 바로가기
JavaScript/React

React Hook Form

by curious week 2025. 5. 15.

https://react-hook-form.com/

 

React Hook Form - performant, flexible and extensible form library

Performant, flexible and extensible forms with easy-to-use validation.

react-hook-form.com


1. React Hook Form (RHF) 이란?

✔ 핵심 정의 (상세)

React Hook Form(RHF)은
React의 함수형 컴포넌트에서 "Form 상태 관리", "검증", "에러 처리", "제출 로직"을 간단하고 성능 좋게 관리해주는 라이브러리입니다.
React Hook Form은 React의 Hooks 시스템을 기반으로 한 Form 관리 라이브러리 중 가장 널리 사용되는 표준 라이브러리입니다.


왜 React Hook Form인가?

기존 Form 관리의 문제점:

  • useState로 모든 Input 상태를 관리 → 렌더링 비용 과다
  • 입력 필드가 많을수록 상태 관리 복잡
  • 검증, 에러 관리, submit, UX를 따로 코드에서 복잡하게 구현해야 함

React Hook Form의 접근:

  • Uncontrolled Input 기반 (useRef 방식) → Input 자체가 DOM 상태를 갖고 있고, RHF는 ref만 연결
  • 렌더링 최적화 → 상태 변경 시 Form 전체가 아니라 필요한 필드만 리렌더
  • 검증, 에러, 제출 흐름이 모두 Hook 내부에서 깔끔하게 관리
  • Form을 선언적으로 설계 → 코드 깔끔, 상태 관리 안정

핵심 특징

1. Uncontrolled input 방식 (ref 기반) → 성능 최적화

  • React Hook Form은 input 필드를 DOM과 직접 연결해서 상태를 관리합니다.
  • 그래서 모든 상태가 React state에 직접 들어가지 않고,
    useRef를 통해 입력 필드의 값을 관리 → 상태 변화 시 리렌더 최소화
    큰 Form에서도 빠르고 부드러운 UX 유지 가능
<input {...register('email')} />

2. 내장 검증 + 외부 검증 스키마 지원

  • 기본 제공하는 검증 (required, minLength 등)으로 빠르게 시작 가능
  • 복잡한 비즈니스 검증이 필요하면 Zod, Yup 같은 스키마 검증 라이브러리와 통합 가능 (resolver)

3. React Query, Zustand 등과 강력 호환

  • RHF는 단순히 Form 상태만 관리 →
    서버 상태(React Query), 클라이언트 상태(Zustand, Recoil)와 자연스럽게 조합 가능
  • 예를 들어:
    • onSubmit에서 React Query mutation 호출
    • RHF에서 입력한 데이터 → Zustand에 저장
    • 상태 일관성 쉽게 유지

2. React Hook Form의 내부 동작 원리


✔ 기존 Form 관리 vs RHF 방식 비교

기존 Form 관리 (state 기반)

const [email, setEmail] = useState('');

<input value={email} onChange={(e) => setEmail(e.target.value)} />
  • 입력할 때마다 → setEmail 호출 → React state 업데이트 →
    → 컴포넌트 전체 리렌더링
  • 필드가 많아질수록 → 성능 저하, 복잡도 증가
  • 모든 필드 상태, 에러, 검증 → 직접 관리 필요 (onChange, onBlur 직접 처리)

RHF 방식 (ref 기반, Uncontrolled Input)

const { register } = useForm();

<input {...register('email')} />
  • React state 사용 ❌
  • Input 자체는 DOM이 관리
  • React Hook Form은 ref로 Input과 연결 → 내부적으로 value, error, touched 등을 ref 기반으로 추적
  • 필요한 필드만 리렌더 → Form 전체 리렌더 X

핵심 동작 흐름 (시각적)

[Input 필드]
   ▼  (onChange)
[DOM 내부 상태 관리 (Uncontrolled)]
   ▼
[React Hook Form useRef로 추적]
   ▼
[submit → handleSubmit 호출 시 → 검증 → 상태 업데이트 → errors, isSubmitting 등 업데이트]
   ▼
[FormState만 리렌더 (필요한 부분만)]

 내부 원리 핵심 3단계

1. 등록 register('fieldName') → ref로 Input과 연결
2. 검증 handleSubmit 시점에 내부에서 Input 값 수집 → 검증 수행 (내장 or 스키마)
3. 상태 관리 errors, touched, isSubmitting 등 FormState에서 관리 → 필요한 필드만 리렌더

✔ RHF가 성능이 좋은 이유

Uncontrolled Input Input의 값, 상태가 DOM 안에서 관리 (React가 직접 개입 X)
useRef 기반 추적 Input을 ref로 연결 → 값 추적만 React Hook Form이 관리
최소 렌더링 Form 전체 리렌더가 아닌 필요한 필드만 상태 업데이트 → 리렌더
상태 구독 최적화 formState를 proxy로 관리 → 필요한 필드만 리렌더 감지
  • 기존 방식: 매 입력마다 React에 보고 → 모든 컴포넌트 다시 그림
  • RHF 방식: Input은 자체적으로 동작 → 필요한 시점(submit, onBlur, etc)에만 React Hook Form이介入 → 성능 최적

✔ 실무에서 알아야 할 동작 특성

  • register() → Input과 연결 → 내부적으로 ref와 onChange, onBlur 등 자체 관리
  • handleSubmit() → Form 검증 + onSubmit 호출
  • formState.errors → 에러 상태 관리 (필요할 때만 리렌더)
  • watch(), setValue(), reset() → ref 기반으로 Input 직접 제어 가능

3. RHF 주요 API 핵심 이해

1. register()

✔ 역할

  • Input을 RHF에 등록 → ref 연결 → 자동으로 value, onChange, onBlur 관리
  • 가장 핵심이자 가장 많이 쓰는 API

✔ 사용 예

<input {...register('email', { required: '이메일 필수' })} />

✔ 특징

  • Input에 직접 props spread (...register('필드명'))
  • 필드별 유효성 검증 옵션(required, minLength 등) 지정 가능

2. handleSubmit()

✔ 역할

  • Form 제출을 감싸고 검증 수행 → 검증 성공 시 onSubmit 호출 → 실패 시 formState.errors 갱신

✔ 사용 예

<form onSubmit={handleSubmit((data) => console.log(data))}>

3. formState

✔ 역할

  • Form의 상태, 에러, submit 상태 등 관리

✔ 자주 사용하는 속성

errors 검증 오류 객체
isSubmitting submit 진행 중 여부 (비동기)
isValid 전체 검증 통과 여부
touchedFields 터치된 필드들
dirtyFields 변경된 필드들

✔ 사용 예

if (formState.errors.email) {
  console.log(formState.errors.email.message);
}

4. setValue()

✔ 역할

  • RHF 내부 상태에서 필드 값을 강제로 변경
  • Input을 직접 변경할 필요 없이, Form 내부 상태만 업데이트 가능

✔ 사용 예

setValue('email', 'example@example.com');

✔ 특징

  • 비동기 상태 변경 필요할 때, API 응답 → Form 자동 업데이트
  • UI 동기화 유지 → validation 즉시 반영 가능

5. watch()

✔ 역할

  • 특정 필드 값 실시간 구독
  • 필드의 현재 값을 React state 없이 감시 가능

✔ 사용 예

const emailValue = watch('email');

✔ 특징

  • 실시간 Preview, 동적 UI 제어에 유용

6. reset()

✔ 역할

  • Form 상태 초기화 (필드 값, 에러, touched 전부 초기화 가능)
  • API 응답을 받아서 Form 상태를 새로운 값으로 세팅할 때 유용

✔ 사용 예

reset({ email: 'admin@example.com' });

7. useFieldArray()

✔ 역할

  • 동적 필드 관리 (예: 태그, 참여자 리스트 등 추가/삭제가 자유로운 배열 Form)

✔ 사용 예

const { fields, append, remove } = useFieldArray({ control, name: 'skills' });

fields.map((field, index) => (
  <input {...register(`skills.${index}.name`)} key={field.id} />
));

✔ 특징

  • RHF에서 가장 강력한 기능 중 하나 → 복잡한 Form에서도 성능 저하 없이 동적 필드 관리 가능

useFieldArray 옵션

✔ 기본 형태

const { fields, append, prepend, remove, swap, move, insert, update, replace } = useFieldArray({
  control,
  name: 'skills', // 필드 배열의 name (예: skills, emails)
  keyName: 'id',  // (선택) 기본 'id' → 필드 고유 key 커스터마이징 가능
});

 

✔ 주요 옵션

control useForm에서 반환한 control 필수
name 필드 배열의 이름 (skills, tags 등)
keyName (선택) 기본 'id'. key 프로퍼티 이름 변경 시 사용 (필수는 아님, 성능 최적화 목적)

실무 팁

  • keyName을 기본 'id'로 두고 사용 권장 (React key 최적화)
  • control은 반드시 useForm에서 받은 것을 그대로 넘겨야 정상 동작

반환 값 (API)

fields 현재 필드 배열 상태 ({ id, ...field }[])
append(value) 필드 마지막에 추가
prepend(value) 필드 맨 앞에 추가
remove(index) 특정 인덱스 필드 제거
insert(index, value) 특정 위치에 필드 삽입
swap(indexA, indexB) 두 필드 위치 교환
move(from, to) 필드를 원하는 위치로 이동
update(index, value) 특정 필드 값 직접 업데이트
replace(data[]) 전체 필드 배열 교체

실무 활용 예시

✔ 1. append

append({ name: '' });

✔ 2. remove

remove(1);  // 두 번째 필드 삭제

✔ 3. swap

swap(0, 2);  // 첫 번째와 세 번째 필드 위치 변경

✔ 4. insert

insert(1, { name: 'New Skill' });  // 두 번째 위치에 삽입

✔ 5. update

update(0, { name: 'Updated Skill' });  // 첫 번째 필드 값 업데이트

✔ 6. replace

replace([{ name: 'Skill A' }, { name: 'Skill B' }]);  // 전체 리스트 교체

고급 팁

  • fields는 불변 객체 아님 → React에서 key로 field.id 반드시 사용 (성능 최적화 필수)
  • append, remove 호출 후 → RHF는 자동으로 register 및 검증, 상태 관리
  • replace는 초기값 세팅, 서버에서 받은 리스트로 초기화할 때 매우 유용
  • update를 사용하면 Form 상태만 업데이트 → UI 리렌더 X (성능 최적화용으로 강력)

요약

const { fields, append, remove, swap, move, insert, update, replace } = useFieldArray({
  control,
  name: 'skills',
});
  • 추가: append(), prepend(), insert()
  • 삭제: remove()
  • 수정: update()
  • 위치 변경: swap(), move()
  • 전체 교체: replace()
import { useForm, useFieldArray } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { tagFormSchema, TagFormRequest } from '@/schemas/tagForm';

export const TagForm = () => {
  const { register, handleSubmit, control, formState: { errors, isSubmitting, isValid } } = useForm<TagFormRequest>({
    resolver: zodResolver(tagFormSchema),
    mode: 'onBlur',
    reValidateMode: 'onChange',
    defaultValues: {
      tags: ['React'],
    },
    shouldUnregister: true,
  });

  const { fields, append, remove, replace } = useFieldArray({
    control,
    name: 'tags',
  });

  const onSubmit = (data: TagFormRequest) => {
    console.log('제출 데이터:', data);
  };

  return (
    <form onSubmit={handleSubmit(onSubmit)}>
      {fields.map((field, index) => (
        <div key={field.id}>
          <input
            {...register(`tags.${index}`)}
            placeholder="태그 입력"
            disabled={isSubmitting}
          />
          <button type="button" onClick={() => remove(index)}>
            삭제
          </button>
          {errors.tags?.[index] && <p>{errors.tags[index]?.message}</p>}
        </div>
      ))}

      <button type="button" onClick={() => append('')}>태그 추가</button>
      <button type="button" onClick={() => replace(['React', 'TypeScript'])}>초기화</button>

      <button type="submit" disabled={isSubmitting || !isValid}>
        {isSubmitting ? '처리 중...' : '제출'}
      </button>
    </form>
  );
};

Input 연결 register()
Form 제출 + 검증 handleSubmit()
상태 관리, 에러 추적 formState
필드 값 강제 변경 setValue()
필드 실시간 값 구독 watch()
Form 초기화 reset()
동적 필드 관리 (배열) useFieldArray()

 

4. RHF 검증 흐름 종류

HTML 표준 검증 required, minLength 등 옵션 직접 사용 매우 간단한 Form
커스텀 validator validate 옵션으로 JS 함수 제공 실시간 검증
스키마 기반 resolver (Yup, Zod 등) 사용 복잡, 실무 권장

 

1. 기본 Validator 방식 (HTML 속성 + register 옵션)

✔ 특징

  • register 옵션으로 필드마다 간단한 검증 지정
  • required, minLength, maxLength, pattern 등 HTML 표준 검증
  • 가장 빠르고 간단하지만, 복잡한 검증은 어려움

✔ 코드 예시

<input
  {...register('email', {
    required: '이메일 필수',
    minLength: { value: 5, message: '5자 이상' },
    pattern: { value: /^[^\s@]+@[^\s@]+\.[^\s@]+$/, message: '이메일 형식' }
  })}
/>

✔ 실무 추천 상황

  • 아주 간단한 Form (로그인, 검색 등)
  • 빠르게 프로토타입 만들 때

2. 커스텀 Validator (validate 옵션)

✔ 특징

  • validate 옵션으로 JS 함수 기반 자유로운 검증 가능
  • 필드 값에 따라 동적 검증 가능
  • 비동기 검증(async)도 지원 (단, resolver 사용하는 게 더 좋음)

✔ 코드 예시

<input
  {...register('username', {
    validate: (value) => value.length >= 3 || '3자 이상 입력'
  })}
/>

✔ 실무 추천 상황

  • 필드 단위에서만 간단한 custom 검증
  • cross-field 검증이 필요한 경우는 resolver 추천

3. 스키마 기반 검증 (외부 라이브러리 + resolver)

✔ 특징

  • Yup, Zod, Joi 같은 스키마 검증 라이브러리와 결합
  • resolver를 통해 RHF와 연결 → 검증은 스키마가 담당 → Form은 검증 로직에서 자유로움
  • 복잡한 검증, cross-field, 조건부 검증, API 기반 검증에 최적
  • 검증, DTO, API 타입을 통합 관리 가능

✔ 코드 예시 (Zod 사용)

const schema = z.object({
  email: z.string().email(),
  password: z.string().min(8),
});

const { register, handleSubmit, formState: { errors } } = useForm({
  resolver: zodResolver(schema),
});

✔ 실무 추천 상황

  • 복잡한 Form
  • 대규모 프로젝트
  • 검증 로직과 Form 코드를 완전히 분리하고 싶을 때
  • API 요청 DTO와 검증을 통합 관리하고 싶을 때

 

5. 실무 통합 예제 — RHF + Zod + React Query + UX 최적화

 

전체 흐름 (시각적)

[사용자 입력 (Input)]
    ↓
[React Hook Form]
    ↓
[zodResolver → Zod 스키마 검증]
    ↓
[React Query mutation (signUpApi)]
    ↓
[onMutate → UI 상태 변경 (버튼 비활성화 등)]
    ↓
[onSuccess → 성공 알림, 페이지 이동]
    ↓
[onError → 에러 메시지 Toast, errors 갱신]

실전 코드 예제

1. Zod 스키마 정의 (Form + API DTO 일치)

// schemas/auth/signUp.ts
import { z } from 'zod';

export const signUpSchema = z.object({
  email: z.string().email('유효한 이메일 형식'),
  password: z.string().min(8, '비밀번호는 8자 이상 입력'),
  confirmPassword: z.string(),
}).superRefine((data, ctx) => {
  if (data.password !== data.confirmPassword) {
    ctx.addIssue({
      path: ['confirmPassword'],
      message: '비밀번호가 일치하지 않습니다.',
    });
  }
});

export type SignUpRequest = z.infer<typeof signUpSchema>;

2. API 호출 함수

// api/auth.ts
import axios from '@/lib/axios';
import { SignUpRequest } from '@/schemas/auth/signUp';

export const signUpApi = (data: SignUpRequest) => {
  return axios.post('/api/signup', data);
};

3. React Query mutation 훅

// hooks/useSignUpMutation.ts
import { useMutation } from '@tanstack/react-query';
import { signUpApi } from '@/api/auth';

export const useSignUpMutation = () => {
  return useMutation({
    mutationFn: signUpApi,
    onSuccess: () => {
      alert('회원가입 성공!');
    },
    onError: (error) => {
      alert('회원가입 실패: ' + (error.response?.data?.message ?? '알 수 없는 오류'));
    },
  });
};

4. Form 컴포넌트 (RHF + zodResolver + UX 최적화)

import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { signUpSchema, SignUpRequest } from '@/schemas/auth/signUp';
import { useSignUpMutation } from '@/hooks/useSignUpMutation';

export const SignUpForm = () => {
  const { register, handleSubmit, formState: { errors, isSubmitting } } = useForm<SignUpRequest>({
    resolver: zodResolver(signUpSchema),
    mode: 'onBlur',
    reValidateMode: 'onChange',
  });

  const mutation = useSignUpMutation();

  const onSubmit = (data: SignUpRequest) => {
    mutation.mutate(data);
  };

  return (
    <form onSubmit={handleSubmit(onSubmit)}>
      <input {...register('email')} placeholder="이메일" disabled={isSubmitting} />
      {errors.email && <p>{errors.email.message}</p>}

      <input type="password" {...register('password')} placeholder="비밀번호" disabled={isSubmitting} />
      {errors.password && <p>{errors.password.message}</p>}

      <input type="password" {...register('confirmPassword')} placeholder="비밀번호 확인" disabled={isSubmitting} />
      {errors.confirmPassword && <p>{errors.confirmPassword.message}</p>}

      <button type="submit" disabled={isSubmitting}>
        {isSubmitting ? '가입 중...' : '회원가입'}
      </button>
    </form>
  );
};
isSubmitting submit 중 버튼 비활성화
errors.xxx.message 필드별 에러 표시
mode: 'onBlur' blur 시 검증
reValidateMode: 'onChange' 입력 중 실시간 재검증
mutation.onError API 에러 처리 UX (Toast, alert)
API 요청 DTO & Form 스키마 통합 z.infer<typeof schema>

전체 흐름

  1. 스키마 선언 (검증 로직 → Form + API 일치)
  2. useForm + zodResolver로 Form 상태 관리 + 검증
  3. onSubmit에서 React Query mutation 호출
  4. isSubmitting, onError, onSuccess → UX 최적화
  5. 코드는 깔끔, 상태 일관성 유지, 검증/에러/UI 분리

 


실전 프로젝트 폴더 구조 추천 (역할 분리)

src/
├── api/                     # API 호출 함수 (순수 Axios wrapper, 비즈니스 로직 X)
│   ├── auth.ts
│   └── user.ts
│
├── hooks/                   # React Query custom hooks (useUserQuery, useSignUpMutation)
│   ├── useSignUpMutation.ts
│   └── useUserQuery.ts
│
├── schemas/                 # Zod 스키마 및 타입 관리 (Form + API DTO 통합)
│   ├── auth/
│   │    └── signUp.ts
│   │    └── login.ts
│   └── user/
│
├── components/              # UI 컴포넌트 (Form, Input, Button, etc)
│   ├── forms/
│   │    └── SignUpForm.tsx
│   └── ui/
│        └── Button.tsx
│
├── pages/                   # 페이지 레벨 (Next.js 기준 or React Router 기준)
│   ├── SignUpPage.tsx
│   └── LoginPage.tsx
│
├── stores/                  # Zustand 등 클라이언트 상태 관리
│   └── userStore.ts
│
├── lib/                     # Axios 인스턴스, react-query client, 공통 error handler 등
│   ├── axios.ts
│   ├── react-query.ts
│   └── error-handler.ts
│
└── types/                   # 공통 타입 (API 응답 타입, 유틸 타입)
    └── api.ts

 

계층별 역할 & 책임

api/ 순수 API 호출 함수 (Axios wrapper) UI, 비즈니스 로직 절대 X
hooks/ React Query 상태 관리 (mutation, query) 상태 & side effect 관리
schemas/ Zod 스키마 + 타입 (z.infer) 관리 Form 검증 + API DTO 타입 통합
components/ UI 컴포넌트 (Form, 버튼, 입력 등) Form은 검증 로직 없이 UI만 담당
pages/ 페이지 컴포넌트 (라우팅, SEO, 레이아웃) RHF, Query, 상태 조합
stores/ 클라이언트 상태 (Zustand 등) 서버 상태와 분리
lib/ Axios, QueryClient, 공통 유틸 에러 처리, 인터셉터, 공통 설정
types/ API 응답, 유틸 타입 관리 서버, 클라이언트 타입 일관화

실무 Best Practice 체크리스트

  • API 레이어는 비즈니스 로직 X → Axios만 호출
  • Form은 UI만 → 검증, 상태, mutation은 외부에서 관리
  • Zod 스키마 → DTO 타입 통합 → API와 Form 검증 통일
  • React Query → API 호출 + 캐싱 관리
  • Zustand → 클라이언트 상태만 관리 (서버 상태와 구분)
  • lib/에서 Axios, QueryClient 전역 관리 → 재사용성, 유지보수 향상

참고 흐름

[UI Input] 
   ↓ (register)
[React Hook Form]
   ↓ (zodResolver)
[Zod 스키마 검증]
   ↓ (onSubmit)
[React Query Mutation → API 호출 (Axios)]
   ↓
[성공 → 상태 갱신, 알림]  
[실패 → error-handler UX 처리]

 


onBlur, onChange 최적화 (mode, reValidateMode)

✔ mode 옵션 (최초 검증 시점 제어)

  • RHF에서 초기 검증 시점을 제어하는 옵션
  • Form이 로드되고 사용자가 처음으로 입력 필드를 조작했을 때, 언제 검증을 시작할지 결정
'onSubmit' (기본) Form 제출 시 검증
'onBlur' 필드에서 focus out 될 때 검증
'onChange' 입력 중 실시간 검증
'all' 입력, blur, submit 모두 검증

✔ reValidateMode 옵션 (검증 후 재검증 시점 제어)

  • 한번 검증된 필드를 다시 검증할 시점
  • 사용자가 에러가 있는 필드를 고칠 때, 언제 다시 검증할지 결정

reValidateMode 값 동작

'onChange' 입력 시 다시 검증 (추천)
'onBlur' blur 시점에만 다시 검증

✔ 실무 추천 패턴

useForm({
  mode: 'onBlur',
  reValidateMode: 'onChange',
});
  • UX 최적화 이유:
    • 입력 필드 처음 포커스 아웃 시 검증 (mode: 'onBlur')
    • 검증된 후 수정할 경우 즉시 실시간 검증 (reValidateMode: 'onChange')
    • → 사용자가 불편하지 않게 빠르게 에러 확인 가능
    • → 초반에는 느슨하게, 고칠 때는 적극적으로 검증

✔ 요약 기억

  • mode: 최초 검증 타이밍
  • reValidateMode: 검증된 필드를 다시 검증하는 타이밍

동적 필드 관리 (useFieldArray)

✔ 역할

  • RHF에서 동적 필드 리스트(예: 태그, 참여자, 옵션 등)를 성능 저하 없이 관리
  • 필드 추가/삭제 시 자동으로 register 연결, 검증, 상태 관리됨
  • 복잡한 Form에서도 필드 추가/삭제 시 리렌더 최소화 → 고성능 유지

✔ 기본 사용 흐름

const { control, register, handleSubmit } = useForm({
  defaultValues: {
    skills: [{ name: '' }],
  },
});

const { fields, append, remove } = useFieldArray({
  control,
  name: 'skills',
});

return (
  <>
    {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>
  </>
);

 

✔ 원리

  • useFieldArray는 내부적으로 RHF의 control과 연결
  • 필드 배열(fields)을 생성
  • append, remove 사용 시 → 필드가 DOM에 동적으로 추가/삭제 → RHF가 알아서 상태 관리
  • field.id가 필수 → 리렌더 최적화

실무요령

입력 UX 최적화 mode: 'onBlur', reValidateMode: 'onChange'
동적 리스트 관리 useFieldArray + append, remove
동적 필드 key 최적화 field.id 사용 필수

6. RHF 상태 최적화 및 고급 사용법 (실무 기준)


1. ✔ 상태 최적화 (렌더링 최소화 & 비활성화 필드 관리)

RHF의 기본 렌더링 최적화

  • RHF는 Input을 Uncontrolled 방식 + useRef 기반 관리
  • → Input 상태 변경 시 Form 전체가 리렌더링되지 않고, 필요한 Input만 리렌더링
  • → 불필요한 렌더링 없이 고성능 유지

shouldUnregister: true

  • 조건부 렌더링되는 필드 제거 시 → 해당 필드의 값, 상태, 에러 자동으로 unregister
  • 기본값은 false (숨겨진 필드라도 상태는 계속 유지)
  • true 설정 시 → 비활성화되면 필드 상태, 에러 전부 제거 → Form 클린 상태 유지
const { register } = useForm({
  shouldUnregister: true,
});

✔ Controller (외부 컴포넌트와 연결)

  • register()는 기본 Input, Select, Textarea 같은 기본 HTML Input 요소만 직접 제어
  • React-Select, DatePicker 같은 외부 라이브러리의 경우 → React Hook Form이 직접 value, onChange 관리 불가능
  • → 이때 Controller 사용 필수 → RHF와 외부 컴포넌트 연결
import { Controller } from 'react-hook-form';

<Controller
  control={control}
  name="category"
  render={({ field }) => (
    <Select {...field} options={categoryOptions} />
  )}
/>

2. ✔ 실시간 UI 반응형 Form (watch, setValue, reset)

watch()

  • 필드 값을 실시간으로 구독
  • Form이 리렌더되지 않아도 값 확인 가능
  • UI Preview, 조건부 렌더링 시 사용
const watchedEmail = watch('email');

setValue()

  • Form 내부 상태에서 값을 강제로 변경
  • API 응답 데이터로 Form 값을 세팅하거나, 버튼 클릭 시 값 변경 가능
setValue('email', 'admin@example.com');

reset()

  • Form 상태 전부 초기화 (필드 값, 에러, touched 등)
reset({
  email: '',
  password: '',
});

3. ✔ 에러 UX 최적화

errors map으로 UX 표시

{Object.keys(errors).map((fieldName) => (
  <p key={fieldName}>{errors[fieldName]?.message}</p>
))}

formState.isValid

  • 전체 Form이 유효한지 확인 가능
  • mode: 'onChange' 또는 onBlur 설정 시 실시간 반영됨
  • submit 버튼 비활성화/활성화 제어에 유용
<button type="submit" disabled={!formState.isValid}>
  제출
</button>

실무 UX 최적화 핵심 요령 요약

불필요 렌더 최소화 기본 Uncontrolled Input + shouldUnregister: true
외부 컴포넌트 연결 Controller
실시간 상태 UI watch()
Form 값 변경 setValue(), reset()
에러 UX 최적화 errors map, formState.isValid

7. 실전 최종 예제

상황:
사용자는 관심 태그 추가/삭제가 가능한 회원가입 Form을 입력

  • React Hook Form → Form 상태, 에러, 최적화
  • Zod → 스키마 검증 + API DTO 통합
  • React Query → API 호출 (mutation)
  • useFieldArray → 동적 태그 추가/삭제
  • Controller → 외부 라이브러리(Select) 사용
  • isSubmitting, isValid → UX 최적화

1. Zod 스키마 정의

// schemas/auth/signUp.ts
import { z } from 'zod';

export const signUpSchema = z.object({
  email: z.string().email('유효한 이메일'),
  password: z.string().min(8, '8자 이상 입력'),
  confirmPassword: z.string(),
  tags: z.array(z.string().min(2, '2글자 이상 태그')),
  category: z.string(),
}).superRefine((data, ctx) => {
  if (data.password !== data.confirmPassword) {
    ctx.addIssue({
      path: ['confirmPassword'],
      message: '비밀번호가 일치하지 않습니다.',
    });
  }
});

export type SignUpRequest = z.infer<typeof signUpSchema>;

2. API 호출 (React Query)

// api/auth.ts
import axios from '@/lib/axios';
import { SignUpRequest } from '@/schemas/auth/signUp';

export const signUpApi = (data: SignUpRequest) => {
  return axios.post('/api/signup', data);
};

3. Form 컴포넌트

import { useForm, useFieldArray, Controller } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { useMutation } from '@tanstack/react-query';
import { signUpSchema, SignUpRequest } from '@/schemas/auth/signUp';
import { signUpApi } from '@/api/auth';
import Select from 'react-select';  // 외부 Select 라이브러리

const categoryOptions = [
  { label: '개발', value: 'dev' },
  { label: '디자인', value: 'design' },
];

export const SignUpForm = () => {
  const { register, handleSubmit, control, watch, setValue, reset, formState: { errors, isSubmitting, isValid } } = useForm<SignUpRequest>({
    resolver: zodResolver(signUpSchema),
    mode: 'onBlur',
    reValidateMode: 'onChange',
    defaultValues: {
      tags: [{ value: '' }],
      category: '',
    },
    shouldUnregister: true,
  });

  const { fields, append, remove } = useFieldArray({
    control,
    name: 'tags',
  });

  const mutation = useMutation({
    mutationFn: signUpApi,
    onSuccess: () => {
      alert('회원가입 성공');
      reset();
    },
    onError: (error) => {
      alert('회원가입 실패: ' + (error.response?.data?.message ?? '알 수 없는 오류'));
    },
  });

  const onSubmit = (data: SignUpRequest) => {
    mutation.mutate(data);
  };

  return (
    <form onSubmit={handleSubmit(onSubmit)}>
      <input {...register('email')} placeholder="이메일" disabled={isSubmitting} />
      {errors.email && <p>{errors.email.message}</p>}

      <input type="password" {...register('password')} placeholder="비밀번호" disabled={isSubmitting} />
      {errors.password && <p>{errors.password.message}</p>}

      <input type="password" {...register('confirmPassword')} placeholder="비밀번호 확인" disabled={isSubmitting} />
      {errors.confirmPassword && <p>{errors.confirmPassword.message}</p>}

      {/* 동적 태그 관리 */}
      {fields.map((field, index) => (
        <div key={field.id}>
          <input {...register(`tags.${index}`)} placeholder="태그" disabled={isSubmitting} />
          <button type="button" onClick={() => remove(index)}>삭제</button>
          {errors.tags?.[index] && <p>{errors.tags[index]?.message}</p>}
        </div>
      ))}
      <button type="button" onClick={() => append('')}>태그 추가</button>

      {/* 외부 Select (Controller 사용) */}
      <Controller
        name="category"
        control={control}
        render={({ field }) => (
          <Select
            {...field}
            options={categoryOptions}
            isDisabled={isSubmitting}
          />
        )}
      />
      {errors.category && <p>{errors.category.message}</p>}

      <button type="submit" disabled={isSubmitting || !isValid}>
        {isSubmitting ? '가입 중...' : '회원가입'}
      </button>
    </form>
  );
};

 

최적화 포인트

Zod 스키마로 검증 + API DTO 통일
React Query mutation으로 API 호출 + 상태 관리
useFieldArray로 동적 태그 추가/삭제
Controller로 외부 Select 라이브러리 연결
watch, setValue, reset으로 상태 제어 및 실시간 Preview 가능
isSubmitting → submit 중 UI 잠금
isValid → submit 버튼 활성화 UX 제어