포스트

React Hook Form 완벽 가이드: 폼 상태 관리와 유효성 검사

React Hook Form의 핵심 개념부터 고급 패턴까지. 성능 최적화, Zod 통합, 복잡한 폼 처리 방법을 실전 예제와 함께 알아봅니다.

React Hook Form 완벽 가이드: 폼 상태 관리와 유효성 검사

개요

React에서 폼을 다루는 것은 생각보다 복잡합니다. 입력 값 관리, 유효성 검사, 에러 처리, 제출 상태 관리 등 고려해야 할 사항이 많습니다. React Hook Form은 이러한 복잡성을 해결하면서도 뛰어난 성능을 제공하는 폼 라이브러리입니다.

이 글에서는 React Hook Form의 기초부터 고급 패턴까지 실전 예제와 함께 상세히 알아봅니다.


React 폼 관리의 어려움

Controlled vs Uncontrolled 컴포넌트

React에서 폼 입력을 처리하는 방식은 크게 두 가지입니다.

Controlled 컴포넌트:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
import { useState } from 'react';

function ControlledForm() {
  const [name, setName] = useState('');
  const [email, setEmail] = useState('');
  const [password, setPassword] = useState('');

  const handleSubmit = (e: React.FormEvent) => {
    e.preventDefault();
    console.log({ name, email, password });
  };

  return (
    <form onSubmit={handleSubmit}>
      <input
        value={name}
        onChange={(e) => setName(e.target.value)}
        placeholder="이름"
      />
      <input
        value={email}
        onChange={(e) => setEmail(e.target.value)}
        placeholder="이메일"
      />
      <input
        type="password"
        value={password}
        onChange={(e) => setPassword(e.target.value)}
        placeholder="비밀번호"
      />
      <button type="submit">제출</button>
    </form>
  );
}

문제점:

  • 입력 필드마다 state와 onChange 핸들러 필요
  • 매 입력마다 컴포넌트 전체 리렌더링
  • 필드가 많아지면 보일러플레이트 코드 급증

Uncontrolled 컴포넌트:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
import { useRef } from 'react';

function UncontrolledForm() {
  const nameRef = useRef<HTMLInputElement>(null);
  const emailRef = useRef<HTMLInputElement>(null);

  const handleSubmit = (e: React.FormEvent) => {
    e.preventDefault();
    console.log({
      name: nameRef.current?.value,
      email: emailRef.current?.value,
    });
  };

  return (
    <form onSubmit={handleSubmit}>
      <input ref={nameRef} placeholder="이름" />
      <input ref={emailRef} placeholder="이메일" />
      <button type="submit">제출</button>
    </form>
  );
}

문제점:

  • 유효성 검사 구현이 복잡
  • 실시간 값 추적이 어려움
  • 폼 상태 관리가 불편

일반적인 폼 요구사항

실제 프로젝트에서는 다음과 같은 기능들이 필요합니다:

요구사항설명
유효성 검사필수 입력, 형식 검증, 조건부 검증
에러 메시지사용자 친화적인 오류 표시
제출 상태로딩, 성공, 실패 상태 관리
동적 필드필드 추가/삭제
성능불필요한 리렌더링 방지

React Hook Form은 이 모든 요구사항을 효율적으로 해결합니다.


React Hook Form 소개

주요 특징

1. 최소한의 리렌더링

React Hook Form은 uncontrolled 컴포넌트 방식을 기반으로 합니다. 입력 값이 변경되어도 전체 폼이 리렌더링되지 않습니다.

2. 간결한 API

1
const { register, handleSubmit } = useForm();

단 두 개의 함수로 기본적인 폼 기능을 구현할 수 있습니다.

3. 뛰어난 TypeScript 지원

제네릭을 통해 폼 데이터 타입을 완벽하게 추론합니다.

4. 작은 번들 크기

의존성 없이 약 8.6KB (gzip 기준)의 작은 크기를 유지합니다.

다른 라이브러리와 비교

특성React Hook FormFormikRedux Form
번들 크기~8.6KB~12.7KB~26.4KB
리렌더링최소화매 입력매 입력
방식UncontrolledControlledControlled
학습 곡선낮음중간높음
TypeScript우수좋음보통

설치 및 기본 사용법

설치

1
2
3
4
5
6
7
8
# npm
npm install react-hook-form

# yarn
yarn add react-hook-form

# pnpm
pnpm add react-hook-form

기본 구조

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
import { useForm } from 'react-hook-form';

interface FormData {
  firstName: string;
  lastName: string;
  email: string;
}

function BasicForm() {
  const {
    register,
    handleSubmit,
    formState: { errors },
  } = useForm<FormData>();

  const onSubmit = (data: FormData) => {
    console.log(data);
  };

  return (
    <form onSubmit={handleSubmit(onSubmit)}>
      <input {...register('firstName')} placeholder="이름" />
      <input {...register('lastName')} placeholder="성" />
      <input {...register('email')} placeholder="이메일" />
      <button type="submit">제출</button>
    </form>
  );
}

useForm 훅 이해하기

useForm은 React Hook Form의 핵심 훅입니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
const {
  register,      // 입력 필드 등록
  handleSubmit,  // 폼 제출 처리
  formState,     // 폼 상태 (errors, isSubmitting 등)
  watch,         // 값 구독 (리렌더링 발생)
  getValues,     // 값 가져오기 (리렌더링 없음)
  setValue,      // 값 설정
  reset,         // 폼 초기화
  trigger,       // 수동 유효성 검사
  control,       // Controller용 객체
} = useForm<FormData>({
  mode: 'onSubmit',           // 유효성 검사 시점
  reValidateMode: 'onChange', // 재검사 시점
  defaultValues: {            // 기본값
    firstName: '',
    lastName: '',
    email: '',
  },
});

mode 옵션

유효성 검사가 실행되는 시점을 설정합니다.

mode설명
onSubmit제출 시에만 검사 (기본값)
onBlur포커스 해제 시 검사
onChange값 변경 시마다 검사
onTouched첫 blur 후 onChange로 전환
allblur와 change 모두에서 검사
1
2
3
const { register, handleSubmit } = useForm({
  mode: 'onBlur', // 포커스를 벗어날 때 검사
});

register 함수

register는 입력 필드를 React Hook Form에 등록합니다.

1
2
3
4
5
6
7
8
9
10
// 기본 사용
<input {...register('username')} />

// register가 반환하는 객체
{
  name: 'username',
  ref: (element) => { /* ref 콜백 */ },
  onChange: (e) => { /* change 핸들러 */ },
  onBlur: (e) => { /* blur 핸들러 */ },
}

handleSubmit 함수

폼 제출을 처리하며, 유효성 검사를 통과한 경우에만 콜백을 실행합니다.

1
2
3
4
5
6
7
8
9
10
11
const onSubmit = (data: FormData) => {
  // 유효성 검사 통과 시 실행
  console.log('성공:', data);
};

const onError = (errors: FieldErrors<FormData>) => {
  // 유효성 검사 실패 시 실행
  console.log('에러:', errors);
};

<form onSubmit={handleSubmit(onSubmit, onError)}>

유효성 검사

기본 Validation 규칙

register 함수의 두 번째 인자로 유효성 규칙을 전달합니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
interface FormData {
  username: string;
  email: string;
  password: string;
  age: number;
  website: string;
}

function ValidationForm() {
  const {
    register,
    handleSubmit,
    formState: { errors },
  } = useForm<FormData>();

  return (
    <form onSubmit={handleSubmit((data) => console.log(data))}>
      {/* 필수 입력 */}
      <input
        {...register('username', {
          required: '사용자명은 필수입니다',
        })}
      />
      {errors.username && <span>{errors.username.message}</span>}

      {/* 이메일 형식 */}
      <input
        {...register('email', {
          required: '이메일은 필수입니다',
          pattern: {
            value: /^[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}$/i,
            message: '올바른 이메일 형식이 아닙니다',
          },
        })}
      />
      {errors.email && <span>{errors.email.message}</span>}

      {/* 최소/최대 길이 */}
      <input
        type="password"
        {...register('password', {
          required: '비밀번호는 필수입니다',
          minLength: {
            value: 8,
            message: '비밀번호는 8자 이상이어야 합니다',
          },
          maxLength: {
            value: 20,
            message: '비밀번호는 20자 이하여야 합니다',
          },
        })}
      />
      {errors.password && <span>{errors.password.message}</span>}

      {/* 숫자 범위 */}
      <input
        type="number"
        {...register('age', {
          required: '나이는 필수입니다',
          min: {
            value: 18,
            message: '18세 이상이어야 합니다',
          },
          max: {
            value: 99,
            message: '99세 이하여야 합니다',
          },
        })}
      />
      {errors.age && <span>{errors.age.message}</span>}

      <button type="submit">제출</button>
    </form>
  );
}

유효성 규칙 정리

규칙설명예시
required필수 입력required: '필수 항목입니다'
minLength최소 길이minLength: { value: 3, message: '3자 이상' }
maxLength최대 길이maxLength: { value: 20, message: '20자 이하' }
min최소 값min: { value: 0, message: '0 이상' }
max최대 값max: { value: 100, message: '100 이하' }
pattern정규식 패턴pattern: { value: /regex/, message: '형식 오류' }
validate커스텀 검증validate: (value) => value === 'test' \|\| '오류'

커스텀 Validation

validate 옵션으로 복잡한 검증 로직을 구현할 수 있습니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
interface FormData {
  password: string;
  confirmPassword: string;
  username: string;
}

function CustomValidationForm() {
  const {
    register,
    handleSubmit,
    watch,
    formState: { errors },
  } = useForm<FormData>();

  const password = watch('password');

  return (
    <form onSubmit={handleSubmit((data) => console.log(data))}>
      <input
        type="password"
        {...register('password', {
          required: '비밀번호를 입력하세요',
          validate: {
            // 여러 검증 규칙을 객체로 전달
            hasUpperCase: (value) =>
              /[A-Z]/.test(value) || '대문자를 포함해야 합니다',
            hasLowerCase: (value) =>
              /[a-z]/.test(value) || '소문자를 포함해야 합니다',
            hasNumber: (value) =>
              /[0-9]/.test(value) || '숫자를 포함해야 합니다',
            hasSpecialChar: (value) =>
              /[!@#$%^&*]/.test(value) || '특수문자를 포함해야 합니다',
          },
        })}
        placeholder="비밀번호"
      />
      {errors.password && <span>{errors.password.message}</span>}

      <input
        type="password"
        {...register('confirmPassword', {
          required: '비밀번호 확인을 입력하세요',
          validate: (value) =>
            value === password || '비밀번호가 일치하지 않습니다',
        })}
        placeholder="비밀번호 확인"
      />
      {errors.confirmPassword && (
        <span>{errors.confirmPassword.message}</span>
      )}

      {/* 비동기 검증 */}
      <input
        {...register('username', {
          required: '사용자명을 입력하세요',
          validate: async (value) => {
            // API 호출로 중복 확인
            const response = await fetch(
              `/api/check-username?username=${value}`
            );
            const { available } = await response.json();
            return available || '이미 사용 중인 사용자명입니다';
          },
        })}
        placeholder="사용자명"
      />
      {errors.username && <span>{errors.username.message}</span>}

      <button type="submit">제출</button>
    </form>
  );
}

에러 메시지 컴포넌트

에러 표시를 위한 재사용 가능한 컴포넌트를 만들 수 있습니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
import { FieldError } from 'react-hook-form';

interface ErrorMessageProps {
  error?: FieldError;
  className?: string;
}

function ErrorMessage({ error, className = '' }: ErrorMessageProps) {
  if (!error) return null;

  return (
    <span className={`text-red-500 text-sm ${className}`}>
      {error.message}
    </span>
  );
}

// 사용 예시
function Form() {
  const { register, formState: { errors } } = useForm();

  return (
    <div>
      <input {...register('email', { required: '이메일 필수' })} />
      <ErrorMessage error={errors.email} />
    </div>
  );
}

Zod 통합

Zod는 TypeScript 우선 스키마 선언 및 유효성 검사 라이브러리입니다. React Hook Form과 함께 사용하면 강력한 타입 안전성을 얻을 수 있습니다.

설치

1
npm install zod @hookform/resolvers

기본 사용법

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';

// Zod 스키마 정의
const schema = z.object({
  username: z
    .string()
    .min(3, '사용자명은 3자 이상이어야 합니다')
    .max(20, '사용자명은 20자 이하여야 합니다'),
  email: z
    .string()
    .email('올바른 이메일 형식이 아닙니다'),
  password: z
    .string()
    .min(8, '비밀번호는 8자 이상이어야 합니다')
    .regex(/[A-Z]/, '대문자를 포함해야 합니다')
    .regex(/[0-9]/, '숫자를 포함해야 합니다'),
});

// 스키마에서 타입 추론
type FormData = z.infer<typeof schema>;

function ZodForm() {
  const {
    register,
    handleSubmit,
    formState: { errors },
  } = useForm<FormData>({
    resolver: zodResolver(schema),
  });

  const onSubmit = (data: FormData) => {
    console.log(data);
  };

  return (
    <form onSubmit={handleSubmit(onSubmit)}>
      <div>
        <input {...register('username')} placeholder="사용자명" />
        {errors.username && <span>{errors.username.message}</span>}
      </div>

      <div>
        <input {...register('email')} placeholder="이메일" />
        {errors.email && <span>{errors.email.message}</span>}
      </div>

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

      <button type="submit">제출</button>
    </form>
  );
}

고급 Zod 스키마

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
import { z } from 'zod';

// 비밀번호 확인이 포함된 스키마
const signupSchema = z
  .object({
    username: z
      .string()
      .min(3, '3자 이상 입력하세요')
      .regex(/^[a-zA-Z0-9_]+$/, '영문, 숫자, 밑줄만 사용 가능합니다'),
    email: z.string().email('올바른 이메일을 입력하세요'),
    password: z
      .string()
      .min(8, '8자 이상 입력하세요')
      .regex(/[A-Z]/, '대문자 포함 필수')
      .regex(/[a-z]/, '소문자 포함 필수')
      .regex(/[0-9]/, '숫자 포함 필수')
      .regex(/[^A-Za-z0-9]/, '특수문자 포함 필수'),
    confirmPassword: z.string(),
    age: z.coerce // 문자열을 숫자로 변환
      .number()
      .min(18, '18세 이상이어야 합니다')
      .max(120, '올바른 나이를 입력하세요'),
    terms: z.literal(true, {
      errorMap: () => ({ message: '약관에 동의해야 합니다' }),
    }),
  })
  .refine((data) => data.password === data.confirmPassword, {
    message: '비밀번호가 일치하지 않습니다',
    path: ['confirmPassword'], // 에러가 표시될 필드
  });

type SignupFormData = z.infer<typeof signupSchema>;

조건부 유효성 검사

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
const orderSchema = z
  .object({
    deliveryType: z.enum(['pickup', 'delivery']),
    address: z.string().optional(),
    zipCode: z.string().optional(),
  })
  .refine(
    (data) => {
      if (data.deliveryType === 'delivery') {
        return data.address && data.address.length > 0;
      }
      return true;
    },
    {
      message: '배송 선택 시 주소는 필수입니다',
      path: ['address'],
    }
  )
  .refine(
    (data) => {
      if (data.deliveryType === 'delivery') {
        return data.zipCode && /^\d{5}$/.test(data.zipCode);
      }
      return true;
    },
    {
      message: '올바른 우편번호를 입력하세요',
      path: ['zipCode'],
    }
  );

Zod와 함께 사용하는 완전한 예제

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';

const profileSchema = z.object({
  name: z.string().min(2, '이름은 2자 이상이어야 합니다'),
  bio: z.string().max(200, '소개는 200자 이하여야 합니다').optional(),
  website: z
    .string()
    .url('올바른 URL을 입력하세요')
    .optional()
    .or(z.literal('')), // 빈 문자열 허용
  birthDate: z.coerce.date().max(new Date(), '미래 날짜는 선택할 수 없습니다'),
  role: z.enum(['user', 'admin', 'moderator'], {
    errorMap: () => ({ message: '역할을 선택하세요' }),
  }),
});

type ProfileFormData = z.infer<typeof profileSchema>;

function ProfileForm() {
  const {
    register,
    handleSubmit,
    formState: { errors, isSubmitting },
  } = useForm<ProfileFormData>({
    resolver: zodResolver(profileSchema),
    defaultValues: {
      name: '',
      bio: '',
      website: '',
      role: 'user',
    },
  });

  const onSubmit = async (data: ProfileFormData) => {
    await new Promise((resolve) => setTimeout(resolve, 1000));
    console.log('제출된 데이터:', data);
  };

  return (
    <form onSubmit={handleSubmit(onSubmit)} className="space-y-4">
      <div>
        <label htmlFor="name">이름</label>
        <input id="name" {...register('name')} />
        {errors.name && (
          <p className="text-red-500">{errors.name.message}</p>
        )}
      </div>

      <div>
        <label htmlFor="bio">소개</label>
        <textarea id="bio" {...register('bio')} />
        {errors.bio && (
          <p className="text-red-500">{errors.bio.message}</p>
        )}
      </div>

      <div>
        <label htmlFor="website">웹사이트</label>
        <input id="website" {...register('website')} />
        {errors.website && (
          <p className="text-red-500">{errors.website.message}</p>
        )}
      </div>

      <div>
        <label htmlFor="birthDate">생년월일</label>
        <input id="birthDate" type="date" {...register('birthDate')} />
        {errors.birthDate && (
          <p className="text-red-500">{errors.birthDate.message}</p>
        )}
      </div>

      <div>
        <label htmlFor="role">역할</label>
        <select id="role" {...register('role')}>
          <option value="">선택하세요</option>
          <option value="user">사용자</option>
          <option value="admin">관리자</option>
          <option value="moderator">모더레이터</option>
        </select>
        {errors.role && (
          <p className="text-red-500">{errors.role.message}</p>
        )}
      </div>

      <button type="submit" disabled={isSubmitting}>
        {isSubmitting ? '저장 중...' : '저장'}
      </button>
    </form>
  );
}

고급 패턴

useFieldArray - 동적 필드 관리

useFieldArray는 동적으로 필드를 추가/삭제할 수 있게 해줍니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
import { useForm, useFieldArray } from 'react-hook-form';

interface FormData {
  users: {
    name: string;
    email: string;
  }[];
}

function DynamicFieldsForm() {
  const { register, control, handleSubmit } = useForm<FormData>({
    defaultValues: {
      users: [{ name: '', email: '' }],
    },
  });

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

  const onSubmit = (data: FormData) => {
    console.log(data);
  };

  return (
    <form onSubmit={handleSubmit(onSubmit)}>
      {fields.map((field, index) => (
        <div key={field.id} className="flex gap-2 mb-2">
          <input
            {...register(`users.${index}.name` as const)}
            placeholder="이름"
          />
          <input
            {...register(`users.${index}.email` as const)}
            placeholder="이메일"
          />
          <button type="button" onClick={() => remove(index)}>
            삭제
          </button>
          {index > 0 && (
            <button type="button" onClick={() => move(index, index - 1)}>
              위로
            </button>
          )}
        </div>
      ))}

      <button
        type="button"
        onClick={() => append({ name: '', email: '' })}
      >
        사용자 추가
      </button>

      <button type="submit">제출</button>
    </form>
  );
}

useFieldArray 메서드 정리

메서드설명
append(obj)배열 끝에 항목 추가
prepend(obj)배열 앞에 항목 추가
insert(index, obj)특정 위치에 항목 삽입
remove(index)특정 위치 항목 삭제
swap(from, to)두 항목 위치 교환
move(from, to)항목을 다른 위치로 이동
update(index, obj)특정 위치 항목 업데이트
replace(arr)전체 배열 교체

useWatch - 값 구독

useWatch는 특정 필드의 값을 구독합니다. watch와 달리 독립적인 리렌더링을 발생시킵니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
import { useForm, useWatch } from 'react-hook-form';

interface FormData {
  firstName: string;
  lastName: string;
}

function WatchExample() {
  const { register, control } = useForm<FormData>();

  return (
    <form>
      <input {...register('firstName')} />
      <input {...register('lastName')} />
      <Preview control={control} />
    </form>
  );
}

// 별도 컴포넌트에서 값 구독 - 이 컴포넌트만 리렌더링됨
function Preview({ control }: { control: any }) {
  const [firstName, lastName] = useWatch({
    control,
    name: ['firstName', 'lastName'],
  });

  return (
    <p>
      미리보기: {firstName} {lastName}
    </p>
  );
}

useFormContext - 컨텍스트 공유

FormProvideruseFormContext를 사용하면 중첩된 컴포넌트에서 폼 메서드에 접근할 수 있습니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
import {
  useForm,
  FormProvider,
  useFormContext,
} from 'react-hook-form';

interface FormData {
  email: string;
  password: string;
  profile: {
    name: string;
    bio: string;
  };
}

// 부모 컴포넌트
function ParentForm() {
  const methods = useForm<FormData>();

  const onSubmit = (data: FormData) => {
    console.log(data);
  };

  return (
    <FormProvider {...methods}>
      <form onSubmit={methods.handleSubmit(onSubmit)}>
        <EmailInput />
        <PasswordInput />
        <ProfileSection />
        <button type="submit">제출</button>
      </form>
    </FormProvider>
  );
}

// 자식 컴포넌트 - props 없이 폼 메서드 사용
function EmailInput() {
  const { register, formState: { errors } } = useFormContext<FormData>();

  return (
    <div>
      <input {...register('email')} placeholder="이메일" />
      {errors.email && <span>{errors.email.message}</span>}
    </div>
  );
}

function PasswordInput() {
  const { register } = useFormContext<FormData>();

  return <input type="password" {...register('password')} />;
}

function ProfileSection() {
  const { register } = useFormContext<FormData>();

  return (
    <div>
      <input {...register('profile.name')} placeholder="이름" />
      <textarea {...register('profile.bio')} placeholder="소개" />
    </div>
  );
}

Controller - UI 라이브러리 통합

외부 UI 라이브러리(MUI, Ant Design 등)와 통합할 때 Controller를 사용합니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
import { useForm, Controller } from 'react-hook-form';
import Select from 'react-select';
import DatePicker from 'react-datepicker';

interface FormData {
  category: { value: string; label: string } | null;
  startDate: Date | null;
  rating: number;
}

function ControllerExample() {
  const { control, handleSubmit } = useForm<FormData>({
    defaultValues: {
      category: null,
      startDate: null,
      rating: 0,
    },
  });

  const categoryOptions = [
    { value: 'tech', label: '기술' },
    { value: 'design', label: '디자인' },
    { value: 'business', label: '비즈니스' },
  ];

  return (
    <form onSubmit={handleSubmit((data) => console.log(data))}>
      {/* react-select 통합 */}
      <Controller
        name="category"
        control={control}
        rules={{ required: '카테고리를 선택하세요' }}
        render={({ field, fieldState: { error } }) => (
          <div>
            <Select
              {...field}
              options={categoryOptions}
              placeholder="카테고리 선택"
            />
            {error && <span>{error.message}</span>}
          </div>
        )}
      />

      {/* DatePicker 통합 */}
      <Controller
        name="startDate"
        control={control}
        rules={{ required: '날짜를 선택하세요' }}
        render={({ field, fieldState: { error } }) => (
          <div>
            <DatePicker
              selected={field.value}
              onChange={field.onChange}
              dateFormat="yyyy-MM-dd"
              placeholderText="날짜 선택"
            />
            {error && <span>{error.message}</span>}
          </div>
        )}
      />

      {/* 커스텀 Rating 컴포넌트 */}
      <Controller
        name="rating"
        control={control}
        rules={{
          validate: (value) => value > 0 || '평점을 선택하세요',
        }}
        render={({ field, fieldState: { error } }) => (
          <div>
            <div className="flex gap-1">
              {[1, 2, 3, 4, 5].map((star) => (
                <button
                  key={star}
                  type="button"
                  onClick={() => field.onChange(star)}
                  className={star <= field.value ? 'text-yellow-400' : ''}
                ></button>
              ))}
            </div>
            {error && <span>{error.message}</span>}
          </div>
        )}
      />

      <button type="submit">제출</button>
    </form>
  );
}

성능 최적화

리렌더링 최소화 원리

React Hook Form이 빠른 이유는 uncontrolled 컴포넌트 방식을 사용하기 때문입니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
// Controlled 방식 - 매 입력마다 리렌더링
function ControlledInput() {
  const [value, setValue] = useState('');
  console.log('렌더링!'); // 매번 출력

  return (
    <input
      value={value}
      onChange={(e) => setValue(e.target.value)}
    />
  );
}

// React Hook Form - 리렌더링 없음
function RHFInput() {
  const { register } = useForm();
  console.log('렌더링!'); // 최초 1회만 출력

  return <input {...register('name')} />;
}

formState 구독 최적화

formState를 구조 분해하면 해당 상태가 변경될 때만 리렌더링됩니다.

1
2
3
4
5
6
7
// 모든 formState 변경에 리렌더링 (비권장)
const { formState } = useForm();
console.log(formState.errors); // 전체 formState 구독

// 필요한 것만 구독 (권장)
const { formState: { errors, isSubmitting } } = useForm();
// errors나 isSubmitting이 변경될 때만 리렌더링

조건부 렌더링 최적화

에러 메시지 컴포넌트를 분리하면 불필요한 리렌더링을 방지할 수 있습니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
import { useFormState } from 'react-hook-form';

// 에러 상태만 구독하는 독립적인 컴포넌트
function ErrorDisplay({ name, control }: { name: string; control: any }) {
  const { errors } = useFormState({ control, name });
  const error = errors[name];

  if (!error) return null;
  return <span className="text-red-500">{error.message as string}</span>;
}

function OptimizedForm() {
  const { register, control, handleSubmit } = useForm();

  return (
    <form onSubmit={handleSubmit((data) => console.log(data))}>
      <input {...register('email', { required: '필수' })} />
      <ErrorDisplay name="email" control={control} />

      <input {...register('password', { required: '필수' })} />
      <ErrorDisplay name="password" control={control} />

      <button type="submit">제출</button>
    </form>
  );
}

DevTools 사용법

React Hook Form DevTools를 사용하면 폼 상태를 실시간으로 디버깅할 수 있습니다.

1
npm install -D @hookform/devtools
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
import { useForm } from 'react-hook-form';
import { DevTool } from '@hookform/devtools';

function FormWithDevTools() {
  const { register, control, handleSubmit } = useForm();

  return (
    <>
      <form onSubmit={handleSubmit((data) => console.log(data))}>
        <input {...register('firstName')} />
        <input {...register('lastName')} />
        <button type="submit">제출</button>
      </form>

      {/* 개발 환경에서만 렌더링 */}
      {process.env.NODE_ENV === 'development' && (
        <DevTool control={control} />
      )}
    </>
  );
}

실전 예제

회원가입 폼

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';

const signupSchema = z
  .object({
    email: z.string().email('올바른 이메일을 입력하세요'),
    password: z
      .string()
      .min(8, '비밀번호는 8자 이상이어야 합니다')
      .regex(/[A-Z]/, '대문자를 포함해야 합니다')
      .regex(/[0-9]/, '숫자를 포함해야 합니다'),
    confirmPassword: z.string(),
    name: z.string().min(2, '이름은 2자 이상이어야 합니다'),
    phone: z
      .string()
      .regex(/^01[0-9]-\d{3,4}-\d{4}$/, '올바른 전화번호 형식이 아닙니다'),
    birthYear: z.coerce
      .number()
      .min(1900, '올바른 연도를 입력하세요')
      .max(new Date().getFullYear(), '올바른 연도를 입력하세요'),
    gender: z.enum(['male', 'female', 'other'], {
      errorMap: () => ({ message: '성별을 선택하세요' }),
    }),
    terms: z.literal(true, {
      errorMap: () => ({ message: '이용약관에 동의해주세요' }),
    }),
    marketing: z.boolean().optional(),
  })
  .refine((data) => data.password === data.confirmPassword, {
    message: '비밀번호가 일치하지 않습니다',
    path: ['confirmPassword'],
  });

type SignupFormData = z.infer<typeof signupSchema>;

function SignupForm() {
  const {
    register,
    handleSubmit,
    formState: { errors, isSubmitting },
    setError,
  } = useForm<SignupFormData>({
    resolver: zodResolver(signupSchema),
    defaultValues: {
      marketing: false,
    },
  });

  const onSubmit = async (data: SignupFormData) => {
    try {
      const response = await fetch('/api/signup', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify(data),
      });

      if (!response.ok) {
        const errorData = await response.json();

        // 서버 에러를 폼 에러로 설정
        if (errorData.field) {
          setError(errorData.field, {
            type: 'server',
            message: errorData.message,
          });
        }
        return;
      }

      alert('회원가입이 완료되었습니다!');
    } catch (error) {
      setError('root', {
        type: 'server',
        message: '서버 오류가 발생했습니다. 다시 시도해주세요.',
      });
    }
  };

  return (
    <form onSubmit={handleSubmit(onSubmit)} className="max-w-md mx-auto">
      {errors.root && (
        <div className="bg-red-100 p-3 rounded mb-4">
          {errors.root.message}
        </div>
      )}

      <div className="mb-4">
        <label className="block mb-1">이메일</label>
        <input
          type="email"
          {...register('email')}
          className="w-full border p-2 rounded"
        />
        {errors.email && (
          <p className="text-red-500 text-sm">{errors.email.message}</p>
        )}
      </div>

      <div className="mb-4">
        <label className="block mb-1">비밀번호</label>
        <input
          type="password"
          {...register('password')}
          className="w-full border p-2 rounded"
        />
        {errors.password && (
          <p className="text-red-500 text-sm">{errors.password.message}</p>
        )}
      </div>

      <div className="mb-4">
        <label className="block mb-1">비밀번호 확인</label>
        <input
          type="password"
          {...register('confirmPassword')}
          className="w-full border p-2 rounded"
        />
        {errors.confirmPassword && (
          <p className="text-red-500 text-sm">
            {errors.confirmPassword.message}
          </p>
        )}
      </div>

      <div className="mb-4">
        <label className="block mb-1">이름</label>
        <input
          {...register('name')}
          className="w-full border p-2 rounded"
        />
        {errors.name && (
          <p className="text-red-500 text-sm">{errors.name.message}</p>
        )}
      </div>

      <div className="mb-4">
        <label className="block mb-1">전화번호</label>
        <input
          {...register('phone')}
          placeholder="010-1234-5678"
          className="w-full border p-2 rounded"
        />
        {errors.phone && (
          <p className="text-red-500 text-sm">{errors.phone.message}</p>
        )}
      </div>

      <div className="mb-4">
        <label className="block mb-1">출생연도</label>
        <input
          type="number"
          {...register('birthYear')}
          className="w-full border p-2 rounded"
        />
        {errors.birthYear && (
          <p className="text-red-500 text-sm">{errors.birthYear.message}</p>
        )}
      </div>

      <div className="mb-4">
        <label className="block mb-1">성별</label>
        <select {...register('gender')} className="w-full border p-2 rounded">
          <option value="">선택하세요</option>
          <option value="male">남성</option>
          <option value="female">여성</option>
          <option value="other">기타</option>
        </select>
        {errors.gender && (
          <p className="text-red-500 text-sm">{errors.gender.message}</p>
        )}
      </div>

      <div className="mb-4">
        <label className="flex items-center gap-2">
          <input type="checkbox" {...register('terms')} />
          <span>이용약관에 동의합니다 (필수)</span>
        </label>
        {errors.terms && (
          <p className="text-red-500 text-sm">{errors.terms.message}</p>
        )}
      </div>

      <div className="mb-6">
        <label className="flex items-center gap-2">
          <input type="checkbox" {...register('marketing')} />
          <span>마케팅 정보 수신에 동의합니다 (선택)</span>
        </label>
      </div>

      <button
        type="submit"
        disabled={isSubmitting}
        className="w-full bg-blue-500 text-white p-3 rounded disabled:opacity-50"
      >
        {isSubmitting ? '처리 중...' : '회원가입'}
      </button>
    </form>
  );
}

다단계 폼 (Multi-step Form)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
import { useState } from 'react';
import { useForm, FormProvider, useFormContext } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';

// 각 단계별 스키마
const step1Schema = z.object({
  email: z.string().email('올바른 이메일을 입력하세요'),
  password: z.string().min(8, '8자 이상 입력하세요'),
});

const step2Schema = z.object({
  name: z.string().min(2, '2자 이상 입력하세요'),
  phone: z.string().min(10, '올바른 전화번호를 입력하세요'),
});

const step3Schema = z.object({
  address: z.string().min(5, '주소를 입력하세요'),
  zipCode: z.string().regex(/^\d{5}$/, '5자리 우편번호를 입력하세요'),
});

// 전체 스키마
const fullSchema = step1Schema.merge(step2Schema).merge(step3Schema);
type FormData = z.infer<typeof fullSchema>;

const schemas = [step1Schema, step2Schema, step3Schema];

function MultiStepForm() {
  const [step, setStep] = useState(0);

  const methods = useForm<FormData>({
    resolver: zodResolver(fullSchema),
    mode: 'onChange',
  });

  const onSubmit = async (data: FormData) => {
    console.log('최종 데이터:', data);
    alert('폼 제출 완료!');
  };

  const nextStep = async () => {
    const currentSchema = schemas[step];
    const fields = Object.keys(currentSchema.shape) as (keyof FormData)[];

    // 현재 단계 필드만 검증
    const isValid = await methods.trigger(fields);

    if (isValid) {
      setStep((prev) => prev + 1);
    }
  };

  const prevStep = () => {
    setStep((prev) => prev - 1);
  };

  return (
    <FormProvider {...methods}>
      <form onSubmit={methods.handleSubmit(onSubmit)} className="max-w-md mx-auto">
        {/* 진행 표시 */}
        <div className="flex justify-between mb-8">
          {['계정', '개인정보', '주소'].map((label, index) => (
            <div
              key={label}
              className={`flex-1 text-center ${
                index <= step ? 'text-blue-500' : 'text-gray-400'
              }`}
            >
              <div
                className={`w-8 h-8 mx-auto rounded-full flex items-center justify-center ${
                  index <= step ? 'bg-blue-500 text-white' : 'bg-gray-200'
                }`}
              >
                {index + 1}
              </div>
              <span className="text-sm">{label}</span>
            </div>
          ))}
        </div>

        {/* 단계별 컨텐츠 */}
        {step === 0 && <Step1 />}
        {step === 1 && <Step2 />}
        {step === 2 && <Step3 />}

        {/* 네비게이션 버튼 */}
        <div className="flex gap-4 mt-6">
          {step > 0 && (
            <button
              type="button"
              onClick={prevStep}
              className="flex-1 border p-2 rounded"
            >
              이전
            </button>
          )}

          {step < 2 ? (
            <button
              type="button"
              onClick={nextStep}
              className="flex-1 bg-blue-500 text-white p-2 rounded"
            >
              다음
            </button>
          ) : (
            <button
              type="submit"
              className="flex-1 bg-green-500 text-white p-2 rounded"
            >
              완료
            </button>
          )}
        </div>
      </form>
    </FormProvider>
  );
}

function Step1() {
  const { register, formState: { errors } } = useFormContext<FormData>();

  return (
    <div className="space-y-4">
      <h2 className="text-xl font-bold">계정 정보</h2>
      <div>
        <label className="block mb-1">이메일</label>
        <input
          type="email"
          {...register('email')}
          className="w-full border p-2 rounded"
        />
        {errors.email && (
          <p className="text-red-500 text-sm">{errors.email.message}</p>
        )}
      </div>
      <div>
        <label className="block mb-1">비밀번호</label>
        <input
          type="password"
          {...register('password')}
          className="w-full border p-2 rounded"
        />
        {errors.password && (
          <p className="text-red-500 text-sm">{errors.password.message}</p>
        )}
      </div>
    </div>
  );
}

function Step2() {
  const { register, formState: { errors } } = useFormContext<FormData>();

  return (
    <div className="space-y-4">
      <h2 className="text-xl font-bold">개인 정보</h2>
      <div>
        <label className="block mb-1">이름</label>
        <input
          {...register('name')}
          className="w-full border p-2 rounded"
        />
        {errors.name && (
          <p className="text-red-500 text-sm">{errors.name.message}</p>
        )}
      </div>
      <div>
        <label className="block mb-1">전화번호</label>
        <input
          {...register('phone')}
          className="w-full border p-2 rounded"
        />
        {errors.phone && (
          <p className="text-red-500 text-sm">{errors.phone.message}</p>
        )}
      </div>
    </div>
  );
}

function Step3() {
  const { register, formState: { errors } } = useFormContext<FormData>();

  return (
    <div className="space-y-4">
      <h2 className="text-xl font-bold">주소 정보</h2>
      <div>
        <label className="block mb-1">주소</label>
        <input
          {...register('address')}
          className="w-full border p-2 rounded"
        />
        {errors.address && (
          <p className="text-red-500 text-sm">{errors.address.message}</p>
        )}
      </div>
      <div>
        <label className="block mb-1">우편번호</label>
        <input
          {...register('zipCode')}
          className="w-full border p-2 rounded"
        />
        {errors.zipCode && (
          <p className="text-red-500 text-sm">{errors.zipCode.message}</p>
        )}
      </div>
    </div>
  );
}

파일 업로드 폼

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
import { useForm, Controller } from 'react-hook-form';
import { z } from 'zod';
import { zodResolver } from '@hookform/resolvers/zod';

const MAX_FILE_SIZE = 5 * 1024 * 1024; // 5MB
const ACCEPTED_IMAGE_TYPES = ['image/jpeg', 'image/png', 'image/webp'];

const schema = z.object({
  title: z.string().min(1, '제목을 입력하세요'),
  description: z.string().optional(),
  image: z
    .custom<FileList>()
    .refine((files) => files?.length === 1, '이미지를 선택하세요')
    .refine(
      (files) => files?.[0]?.size <= MAX_FILE_SIZE,
      '파일 크기는 5MB 이하여야 합니다'
    )
    .refine(
      (files) => ACCEPTED_IMAGE_TYPES.includes(files?.[0]?.type),
      'JPG, PNG, WebP 형식만 지원합니다'
    ),
  documents: z
    .custom<FileList>()
    .refine((files) => files?.length <= 5, '최대 5개 파일까지 업로드 가능합니다')
    .optional(),
});

type FormData = z.infer<typeof schema>;

function FileUploadForm() {
  const {
    register,
    handleSubmit,
    watch,
    formState: { errors, isSubmitting },
  } = useForm<FormData>({
    resolver: zodResolver(schema),
  });

  const imageFile = watch('image');
  const previewUrl = imageFile?.[0]
    ? URL.createObjectURL(imageFile[0])
    : null;

  const onSubmit = async (data: FormData) => {
    const formData = new FormData();
    formData.append('title', data.title);
    if (data.description) {
      formData.append('description', data.description);
    }
    formData.append('image', data.image[0]);

    if (data.documents) {
      Array.from(data.documents).forEach((file) => {
        formData.append('documents', file);
      });
    }

    const response = await fetch('/api/upload', {
      method: 'POST',
      body: formData,
    });

    if (response.ok) {
      alert('업로드 완료!');
    }
  };

  return (
    <form onSubmit={handleSubmit(onSubmit)} className="max-w-md mx-auto">
      <div className="mb-4">
        <label className="block mb-1">제목</label>
        <input
          {...register('title')}
          className="w-full border p-2 rounded"
        />
        {errors.title && (
          <p className="text-red-500 text-sm">{errors.title.message}</p>
        )}
      </div>

      <div className="mb-4">
        <label className="block mb-1">설명</label>
        <textarea
          {...register('description')}
          className="w-full border p-2 rounded"
        />
      </div>

      <div className="mb-4">
        <label className="block mb-1">대표 이미지</label>
        <input
          type="file"
          accept="image/jpeg,image/png,image/webp"
          {...register('image')}
          className="w-full"
        />
        {errors.image && (
          <p className="text-red-500 text-sm">{errors.image.message as string}</p>
        )}
        {previewUrl && (
          <img
            src={previewUrl}
            alt="미리보기"
            className="mt-2 max-w-full h-40 object-cover rounded"
          />
        )}
      </div>

      <div className="mb-4">
        <label className="block mb-1">첨부 문서 (최대 5개)</label>
        <input
          type="file"
          multiple
          {...register('documents')}
          className="w-full"
        />
        {errors.documents && (
          <p className="text-red-500 text-sm">
            {errors.documents.message as string}
          </p>
        )}
      </div>

      <button
        type="submit"
        disabled={isSubmitting}
        className="w-full bg-blue-500 text-white p-2 rounded disabled:opacity-50"
      >
        {isSubmitting ? '업로드 중...' : '업로드'}
      </button>
    </form>
  );
}

자주 묻는 질문 (FAQ)

Q1. Formik과 React Hook Form 중 무엇을 선택해야 하나요?

A: 대부분의 경우 React Hook Form을 권장합니다.

상황추천
새 프로젝트React Hook Form
성능이 중요한 경우React Hook Form
기존 Formik 프로젝트그대로 유지
복잡한 폼 로직둘 다 가능

React Hook Form이 더 작은 번들 크기와 뛰어난 성능을 제공합니다.


Q2. watchgetValues의 차이는 무엇인가요?

A: 리렌더링 여부가 다릅니다.

1
2
3
4
5
6
7
8
// watch - 값이 변경되면 컴포넌트 리렌더링
const name = watch('name');

// getValues - 리렌더링 없이 값만 가져옴
const handleClick = () => {
  const name = getValues('name');
  console.log(name);
};

사용 가이드:

  • 실시간 UI 반영이 필요하면 watch
  • 이벤트 핸들러에서 값만 필요하면 getValues

Q3. 서버 사이드 에러를 어떻게 처리하나요?

A: setError를 사용합니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
const { setError, clearErrors } = useForm();

const onSubmit = async (data) => {
  try {
    const response = await fetch('/api/submit', {
      method: 'POST',
      body: JSON.stringify(data),
    });

    if (!response.ok) {
      const error = await response.json();

      // 특정 필드 에러
      setError('email', {
        type: 'server',
        message: error.message,
      });

      // 전체 폼 에러
      setError('root.serverError', {
        type: 'server',
        message: '서버 오류가 발생했습니다',
      });
    }
  } catch (e) {
    setError('root.serverError', {
      type: 'network',
      message: '네트워크 오류가 발생했습니다',
    });
  }
};

Q4. 중첩된 객체 필드는 어떻게 다루나요?

A: 점 표기법을 사용합니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
interface FormData {
  user: {
    profile: {
      firstName: string;
      lastName: string;
    };
    settings: {
      notifications: boolean;
    };
  };
}

function NestedForm() {
  const { register } = useForm<FormData>();

  return (
    <form>
      <input {...register('user.profile.firstName')} />
      <input {...register('user.profile.lastName')} />
      <input
        type="checkbox"
        {...register('user.settings.notifications')}
      />
    </form>
  );
}

Q5. 폼 초기값을 비동기로 설정하려면 어떻게 하나요?

A: reset 또는 values 옵션을 사용합니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
// 방법 1: useEffect + reset
function AsyncDefaultValues() {
  const { register, reset } = useForm();

  useEffect(() => {
    async function fetchData() {
      const response = await fetch('/api/user');
      const data = await response.json();
      reset(data); // 폼 초기화
    }
    fetchData();
  }, [reset]);

  return <form>...</form>;
}

// 방법 2: values prop (React Hook Form v7.12+)
function AsyncDefaultValues() {
  const [userData, setUserData] = useState(null);
  const { register } = useForm({
    values: userData, // 값이 변경되면 자동으로 폼 업데이트
  });

  useEffect(() => {
    fetch('/api/user')
      .then((res) => res.json())
      .then(setUserData);
  }, []);

  return <form>...</form>;
}

Q6. 배열 필드의 유효성 검사는 어떻게 하나요?

A: Zod와 useFieldArray를 함께 사용합니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
const schema = z.object({
  items: z
    .array(
      z.object({
        name: z.string().min(1, '이름 필수'),
        quantity: z.number().min(1, '1개 이상'),
      })
    )
    .min(1, '최소 1개 항목 필요')
    .max(10, '최대 10개까지'),
});

function ArrayValidationForm() {
  const { control, register, handleSubmit, formState: { errors } } = useForm({
    resolver: zodResolver(schema),
    defaultValues: {
      items: [{ name: '', quantity: 1 }],
    },
  });

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

  return (
    <form onSubmit={handleSubmit((data) => console.log(data))}>
      {/* 배열 전체 에러 */}
      {errors.items?.root && (
        <p className="text-red-500">{errors.items.root.message}</p>
      )}

      {fields.map((field, index) => (
        <div key={field.id}>
          <input {...register(`items.${index}.name`)} />
          {errors.items?.[index]?.name && (
            <span>{errors.items[index].name.message}</span>
          )}

          <input
            type="number"
            {...register(`items.${index}.quantity`, {
              valueAsNumber: true,
            })}
          />
          {errors.items?.[index]?.quantity && (
            <span>{errors.items[index].quantity.message}</span>
          )}

          <button type="button" onClick={() => remove(index)}>
            삭제
          </button>
        </div>
      ))}

      <button type="button" onClick={() => append({ name: '', quantity: 1 })}>
        추가
      </button>
      <button type="submit">제출</button>
    </form>
  );
}

Q7. disabled 상태인 필드 값은 어떻게 처리되나요?

A: 기본적으로 제출 데이터에서 제외됩니다.

1
2
3
4
5
6
7
8
9
10
// disabled 필드는 제출 데이터에 포함되지 않음
<input {...register('name')} disabled />

// 값을 유지하면서 편집 불가 상태로 만들려면 readOnly 사용
<input {...register('name')} readOnly />

// 또는 shouldUnregister: false 설정
const { register } = useForm({
  shouldUnregister: false,
});

Q8. 실시간 유효성 검사와 제출 시 검사를 함께 사용하려면?

A: mode: 'onTouched'가 좋은 선택입니다.

1
2
3
4
const { register, handleSubmit } = useForm({
  mode: 'onTouched', // 첫 blur 후부터 onChange로 검사
  reValidateMode: 'onChange', // 에러 발생 후 재검사 시점
});
mode동작
onSubmit제출 시에만 검사
onBlur포커스 해제 시 검사
onChange매 입력마다 검사
onTouched첫 blur 후 onChange로 전환 (권장)
allblur + change 모두 검사

마무리

React Hook Form은 React에서 폼을 다루는 가장 효율적인 방법 중 하나입니다.

핵심 포인트 정리

개념설명
useForm폼 상태 관리의 핵심 훅
register입력 필드 등록
handleSubmit폼 제출 + 유효성 검사
formState에러, 제출 상태 등
useFieldArray동적 필드 관리
Controller외부 UI 컴포넌트 통합
Zod스키마 기반 유효성 검사

권장 사항

  1. 새 프로젝트에서는 React Hook Form + Zod 조합 사용
  2. modeonTouched로 설정하여 UX 개선
  3. formState는 필요한 것만 구조 분해하여 성능 최적화
  4. 복잡한 폼은 FormProvider로 컴포넌트 분리
  5. DevTools를 활용하여 디버깅

React Hook Form을 통해 복잡한 폼 로직을 간결하게 관리하고, 뛰어난 사용자 경험을 제공하세요.


참고 자료

이 기사는 저작권자의 CC BY 4.0 라이센스를 따릅니다.