React Hook Form
React Hook Form?
React Hook Form은 Form의 구성요소를 간결하게 사용할 수 있도록 도와주는 라이브러리 입니다. React Hook Form은 state를 통해 input을 관리하지 않고, ref를 활용하여 form을 다루는 것을 최적화 시켜줍니다. 따라서 불필요한 리렌더링을 줄이고, 간결하게 form 내부 요소를 다룰 수 있습니다. React Hook Form을 사용하는 이유를 다음과 같이 정리할 수 있습니다.
- 성능 최적화: React Hook Form은 제어되지 않는 구성 요소를 사용하여 렌더링 성능을 향상시킵니다. 이 방식은 각 입력 필드에 대해 상태 변경 시 전체 컴포넌트의 리렌더링을 방지하여 더 빠른 성능을 제공합니다.
- 간결한 코드: 폼 처리 로직을 훨씬 간단하게 만들 수 있습니다.
useState
나useReducer
를 사용하여 폼 상태를 수동으로 관리할 필요가 없으므로 코드가 훨씬 간결해집니다. - 유효성 검사의 용이성: React Hook Form은 Yup, Zod, Superstruct 등과 같은 외부 유효성 검사 라이브러리와 쉽게 통합될 수 있어 강력하고 유연한 유효성 검사 전략을 제공합니다.
- 타입스크립트 지원: 타입스크립트와의 완벽한 호환성을 제공하여 폼과 관련된 데이터의 타입 안전성을 보장합니다.
- 커스텀 훅: React Hook Form은
useForm
이라는 커스텀 훅을 제공하여 폼의 등록, 제출, 유효성 검사 상태 관리 등을 쉽게 할 수 있습니다. - 에러 핸들링: 폼 필드에 대한 에러 메시지를 쉽게 설정하고 관리할 수 있으며, 각 입력 필드의 유효성 검사 상태에 따라 에러를 동적으로 표시할 수 있습니다.
useForm
useForm
은 React Hook Form에서 제공하는 커스텀 훅으로서 폼의 등록, 제출, 유효성 검사 상태 관리를 모두 도와 줍니다. 간단한 ID와 Password를 받는 로그인 폼이 필요하다고 가정하고 useForm을 활용한 Form 객체를 만들 수 있습니다.
'use client';
import { SubmitHandler, useForm } from 'react-hook-form';
// Form에 사용되는 input의 입력값의 특징을 정의합니다.
interface Form {
id: string;
pw: string;
}
export default function Page() {
const { handleSubmit, register } = useForm<Form>();
// useForm에서 사용될 submit 함수를 만듭니다.
const onSubmit = (data: Form) => {
console.log(data);
};
const inputDesign = 'border-2 border-black px-4 py-2';
return (
<>
{/* submit 함수를 등록합니다. */}
<form
onSubmit={handleSubmit(onSubmit)}
className="flex flex-col px-6 gap-6">
{/* useForm의 register를 활용하여 type key와
일치하는 값을 통해 input을 등록합니다. */}
<input
type="text"
placeholder="id"
{...register('id')}
className={inputDesign}
/>
<input
type="password"
placeholder="password"
className={inputDesign}
{...register('pw')}
/>
{/* form에 등록한 handleSubmit 함수를 트리거하는 버튼입니다.
해당 버튼의 type은 submit이어야 합니다. */}
<button type="submit" className="w-full py-2 bg-black text-white">
제출
</button>
</form>
</>
);
}
button을 클릭하게 되면, input에 등록한 register의 고유 이름에 따라 각 input에 작성한 value가 출력 되는 것을 확인할 수 있습니다.
useForm을 활용한 에러 관리하기
이러한 간단한 등록 방법 이외에 추가적으로 각 input에 특성을 제한하는 것으로 필요 값을 확인할 수 있습니다.
'use client';
import { SubmitHandler, useForm } from 'react-hook-form';
// Form에 사용되는 input의 입력값의 특징을 정의합니다.
interface Form {
id: string;
pw: string;
}
export default function Page() {
const {
handleSubmit,
register,
formState: { errors },
} = useForm<Form>();
// useForm에서 사용될 submit 함수를 만듭니다.
const onSubmit: SubmitHandler<Form> = (data: Form) => {
console.log(data);
};
const inputDesign = 'border-2 border-black px-4 py-2';
return (
<>
{/* submit 함수를 등록합니다. */}
<form
onSubmit={handleSubmit(onSubmit)}
className="flex flex-col px-6 gap-6">
{/* useForm의 register를 활용하여 type key와
일치하는 값을 통해 input을 등록합니다. */}
<input
type="text"
className={inputDesign}
placeholder="id"
{...register('id', {
required: true,
minLength: 4,
maxLength: 20,
pattern: /^[a-zA-Z]+[a-zA-Z0-9]{4,19}$/g,
})}
/>
<input
type="password"
placeholder="password"
className={inputDesign}
{...register('pw')}
/>
{/* form에 등록한 handleSubmit 함수를 트리거하는 버튼입니다.
해당 버튼의 type은 submit이어야 합니다. */}
<button type="submit" className="w-full py-2 bg-black text-white">
제출
</button>
</form>
</>
);
}
다음과 같이 id
로 등록한 input에 조건을 추가하였습니다. 4글자 이상 20자 이하여야 하고, 반드시 값이 입력되어야 합니다. 또한 영어 소문자, 대문자로 시작하고 소문자, 대문자, 숫자를 활용해서 아이디를 조합해야 합니다. 다음과 같은 조합을 만족하지 않는다면, button을 아무리 클릭해도 data를 확인할 수 없습니다. submitHandler
는 value object
를 반환하지 않고 errors object
를 반환하기 때문입니다.
그렇다면 사용자에게 해당 조건을 충족하지 못했을 때, error가 보여주어야 합니다. react hook form은 useForm
에서 반환하는 formState
의 errors
객체에 의하여 각 key
에 맞는 에러를 관리합니다. 초기에는 빈 객체이지만, 상황에 따라서 error가 발생하면, errors 객체에 값이 생성됩니다. register에 등록한 option
별로 발생하는 에러 메시지를 확인하기 위해서는 각 option 별로 {value : “”, message: “”}
꼴로 등록을 하여, 해당 option을 지키지 못했을 때, 띄울 에러 메시지를 등록해야 합니다.
export default function Page() {
const {
handleSubmit,
register,
formState: { errors },
} = useForm<Form>();
// useForm에서 사용될 submit 함수를 만듭니다.
const onSubmit: SubmitHandler<Form> = (data: Form) => {
console.log(data);
};
// name이 id인 input에 연결할 register 등록
const { onChange, onBlur, ...other } = register('id', {
required: { value: true, message: '필수 값입니다.' },
minLength: { value: 4, message: '4글자 이상입니다.' },
maxLength: { value: 20, message: '20글자 이상입니다.' },
pattern: {
value: /^[a-zA-Z]+[a-zA-Z0-9]{4,19}$/g,
message: '영어 숫자 조합',
},
});
// register에서 반환하는 onChange를 연결한 customOnChange
const customOnChange = (event: ChangeEvent<HTMLInputElement>) => {
// React Hook Form의 onChange 호출
onChange(event);
// 여기에 추가 로직을 구현
console.log('현재 값:', event.target.value);
console.log(errors);
};
const inputDesign = 'border-2 border-black px-4 py-2';
return (
<>
{/* submit 함수를 등록합니다. */}
<form
onSubmit={handleSubmit(onSubmit)}
className="flex flex-col px-6 gap-6">
<input
type="text"
className={inputDesign}
placeholder="id"
onChange={customOnChange}
{...other}
/>
{errors.id && <span className="text-red">{errors.id.message}</span>}
</form>
</>
);
}

아직 제출을 클릭하지 않았을 때는, 에러 객체에서 별도의 에러를 만들지 않고 있습니다. 하지만 button을 클릭하여 form을 제출하게 되면 상황에 맞는 에러 객체가 생성이 되고, 미리 만든 에러 메시지가 출력 됩니다. 에러 객체는 register에 등록한 string 값을 기준으로 에러를 관리합니다. 에러가 발생이 되면, 어떤 register option type에서 에러가 발생했는지, error 메시지는 무엇인지, 연결된 ref가 무엇인지 확인할 수 있습니다.


useForm mode
이때 왜 한번 form을 제출하고 난 이후에만 각 input에서 발생하는 에러를 감지할까? 라는 궁금증을 가지게 되었습니다. 해당 이유는 useForm
을 선언할 때, option으로 useForm의 모드를 선언하는 것과 연관이 있었습니다.
기본적으로 useForm
을 할당할 때, option으로 mode를 설정할 수 있습니다. 이때 default 값은 onSubmit
입니다. 해당 mode에 따라 react hook form은 언제 에러를 감지할지 결정하게 됩니다. react hook form에서 제공하는 useForm과 관련된 mode는 다음과 같습니다. "onBlur" | "onChange" | "onSubmit" | "all" | "onTouched" | undefined
onSubmit
- 이 모드는 가장 성능이 좋은 모드로, 폼 제출 시에만 유효성 검사를 실행합니다.
- 사용자가 폼을 제출할 때까지 오류 메시지를 보이지 않으므로, 폼 입력 중 사용자의 방해를 최소화합니다.
onBlur
- 이 모드에서는 입력 필드가 포커스를 잃었을 때 (blur 이벤트 발생 시) 유효성 검사가 실행됩니다.
- 사용자가 다음 입력 필드로 넘어갈 때마다 각 필드의 유효성을 검사할 수 있으며, 그 결과를 즉시 사용자에게 피드백으로 제공합니다.
onChange
- 가장 민감한 모드로, 입력 필드의 값이 변경될 때마다 유효성 검사가 트리거됩니다.
- 이 모드는 실시간 유효성 검사가 필요할 때 유용하지만, 입력이 많은 양식에서는 성능 저하를 일으킬 수 있습니다.
onTouched
- 사용자가 입력 필드에 한 번 이상 터치하거나 타이핑한 후에만 유효성 검사를 트리거합니다.
- 이 모드는 모바일 장치 사용자에게 유용하며, 사용자가 입력을 시작한 후에만 오류를 보여줍니다.
all
- 모든 입력, 터치, 블러 이벤트에서 유효성 검사를 실행합니다.
- 가장 엄격한 유효성 검사 방식으로, 어떤 변경이든 즉시 반응하여 폼의 유효성을 확인합니다.
formState
React Hook Form의 **formState
**는 폼의 상태에 대한 정보를 제공하는 객체입니다. 이 객체를 통해 폼의 다양한 상태를 모니터링할 수 있으며, 이는 유효성 검사, 사용자 상호작용 및 폼 제출의 피드백을 제공하는 데 유용합니다. useForm
훅을 사용할 때 **formState
**를 구조 분해 할당하여 사용할 수 있습니다.
isDirty
- 폼의 입력 값이 초기 값과 다른지 여부를 나타냅니다.
- 사용자가 입력을 변경했지만, 아직 제출하지 않은 경우 true가 됩니다.
isSubmitted
- 폼이 한 번이라도 제출되었는지 나타냅니다.
- 이 상태는 폼 제출 후에도 true 상태를 유지합니다.
isSubmitting
- 폼이 현재 제출 중 인지를 나타냅니다.
- 폼 데이터가 서버로 전송 중일 때 유용하게 사용할 수 있습니다.
submitCount
- 폼이 제출된 횟수를 나타냅니다.
- 사용자가 폼을 몇 번 제출했는지 추적할 때 사용됩니다.
isValid
- 폼의 유효성 검사 상태를 나타냅니다.
- 모든 필드의 유효성 검사 규칙이 충족되면 true가 됩니다.
React hook form과 유효성 라이브러리 연결하기
react hook form에서는 유효성 라이브러리와 함께 사용하는 것을 권장하고 있습니다. zod, yup 등의 유효성 라이브러리와 함께 사용함으로서 얻을 수 있는 이점은 다음과 같습니다. 저는 zod를 중심으로 해당 장점에 대해서 정리해보았습니다.
- 타입 안전성
Zod를 사용하면 폼의 입력 값에 대한 타입을 정의하고 검증할 수 있어, TypeScript와의 통합이 매우 강력합니다. 이는 개발 과정에서 타입 오류를 미연에 방지하고, 코드의 타입 안전성을 보장합니다. React Hook Form과 결합하면, 폼 데이터의 구조와 유효성을 한 눈에 파악할 수 있고, 타입스크립트의 전체적인 이점을 폼 관리에 적용할 수 있습니다.
- 유효성 검사 규칙의 중앙 관리
Zod를 사용하면 유효성 검사 규칙을 스키마 형태로 중앙에서 관리할 수 있습니다. 이 스키마는 다른 컴포넌트나 로직에서 재사용할 수 있으며, 유지보수가 용이합니다. 폼의 유효성 검사 로직을 한 곳에서 관리함으로써 일관성을 유지하고 오류 가능성을 줄일 수 있습니다.
- 개발 효율성 향상
Zod는 간결하고 직관적인 API를 제공하여 유효성 검사 로직을 빠르고 쉽게 구현할 수 있습니다. React Hook Form의 resolver
를 통해 Zod 스키마를 쉽게 통합할 수 있으며, 이는 폼의 유효성 검사를 더욱 간단하고 효과적으로 만듭니다.
- 런타임 에러 감소
Zod는 실행 시(runtime) 데이터 구조와 타입을 검증하여, 예상치 못한 데이터 형식으로 인한 오류를 방지합니다. 폼 데이터가 외부 API로부터 오거나 다른 불확실한 소스에서 입력될 때, Zod는 데이터의 유효성을 검사하여 안정성을 높여줍니다.
- 에러 핸들링
Zod와 React Hook Form을 함께 사용하면, 에러 핸들링을 효과적으로 할 수 있습니다. Zod는 유효성 검사 과정에서 발생하는 모든 문제에 대해 상세한 에러 정보를 제공하며, 이를 React Hook Form과 연동하여 사용자에게 명확한 피드백을 제공할 수 있습니다.
"use client";
import { zodResolver } from "@hookform/resolvers/zod";
import { SubmitHandler, useForm } from "react-hook-form";
import { z } from "zod";
const schema = z.object({
id: z
.string()
.min(4, "4글자 이상이어야 합니다.")
.max(20, "20글자 이하여야 합니다.")
.regex(
/^[a-zA-Z]+[a-zA-z0-9]{4,19}$/,
"영어로 시작해야 하며 영어 숫자의 조합만 허용합니다."
),
pw: z
.string()
.min(8, "비밀번호는 8자 이상이어야 합니다.")
.regex(
/^(?=.*[a-zA-z])(?=.*[0-9])(?=.*[$\`~!@$!%*#^?&\\(\\)\-_=+]).{8,16}$/,
"▸ [strong 모드] 8자 ~ — 영문, 숫자, 특수문자 최소 한가지 조합"
),
});
type Schema = z.infer<typeof schema>;
export default function Page() {
const { handleSubmit, register, formState } =
useForm <
Schema >
{
mode: "onChange",
resolver: zodResolver(schema),
};
const { errors } = formState;
// useForm에서 사용될 submit 함수를 만듭니다.
const onSubmit: SubmitHandler<Schema> = (data: Schema) => {
console.log(data);
};
const inputDesign = "border-2 border-black px-4 py-2";
return (
<>
<form
onSubmit={handleSubmit(onSubmit)}
className="flex flex-col px-6 gap-6"
>
<input
type="text"
className={inputDesign}
placeholder="id"
{...register("id")}
/>
{errors.id && <span className="text-red-400">{errors.id.message}</span>}
<input
type="password"
placeholder="password"
className={inputDesign}
{...register("pw")}
/>
{errors.pw && <span className="text-red-400">{errors.pw.message}</span>}
<button type="submit" className="w-full py-2 bg-black text-white">
제출
</button>
</form>
</>
);
}
Custom Form, Input 만들기
reactHookForm과 기존에 만든 Custom Form, Custom Input, Custom Button을 연결해서 일괄적으로 react hook form을 적용해서 더 효율적으로 Form을 관리할 수 있습니다. 합성컴포넌트 방식을 활용해서 Form을 다음과 같이 활용하였습니다.
forwardRef를 활용해서 상위 컴포넌트에서 전달하는 ref를 추가적으로 받을 수 있도록 하였으며, useFormContext를 활용해서 합성 컴포넌트에서 register와 formState에 접근할 수 있도록 하였습니다.
'use client';
import { zodResolver } from '@hookform/resolvers/zod';
import { cn } from '@lib/utils';
import { AnimatePresence, motion } from 'framer-motion';
import {
HTMLAttributes,
HTMLInputTypeAttribute,
forwardRef,
useState,
} from 'react';
import {
ChangeHandler,
FieldValues,
FormProvider,
RegisterOptions,
SubmitHandler,
useForm,
useFormContext,
} from 'react-hook-form';
import { BsEyeFill, BsEyeSlashFill } from 'react-icons/bs';
import { ZodType } from 'zod';
import Button, { ButtonProps } from '../button';
import { variants } from './motion';
export type FormProps<TFieldValues extends FieldValues> = Omit<
HTMLAttributes<HTMLFormElement>,
'onSubmit'
> & {
onSubmit: SubmitHandler<TFieldValues>;
schema: ZodType<TFieldValues>;
validateOn?: 'onSubmit' | 'onBlur' | 'onChange' | 'onTouched' | 'all';
};
type InputProps = {
className?: string;
name: string;
type: HTMLInputTypeAttribute;
label?: string;
pattern?: string;
inputMode?: HTMLAttributes<HTMLInputElement>['inputMode'];
placeholder?: string;
value?: string;
onChange?: ChangeHandler;
options?: RegisterOptions;
required?: boolean;
disabled?: boolean;
customError?: string;
};
type InputSubComponents = Omit<InputProps, 'type' | 'name'> & {
name?: string;
};
export default function Form<T extends FieldValues>({
children,
className,
onSubmit,
schema,
validateOn = 'onSubmit',
...props
}: FormProps<T>) {
const method = useForm<T>({
resolver: zodResolver(schema),
mode: validateOn,
delayError: 500,
});
return (
<FormProvider {...method}>
{/* FormProvider를 활용하여 현재 Form에 등록된 useForm 형태에 접근할 수 있도록 하였습니다. */}
<form
onSubmit={method.handleSubmit(onSubmit)}
className={cn(
'flex flex-col items-center justify-start w-full gap-6',
className,
)}
{...props}>
{children}
</form>
</FormProvider>
);
}
// framerMoiton을 활용하여 input 요소에 animation 요소를 추가했습니다.
const Input = forwardRef<HTMLInputElement, InputProps>(function (
{ className, name, options, label, disabled, customError, ...props },
reference,
) {
const { register, formState } = useFormContext();
const { ref, ...rest } = register(name, options);
const { errors } = formState;
return (
<div className="flex w-full flex-col gap-2">
{label && (
<label
htmlFor={name}
className="text-xs dark:text-neutral-500 text-neutral-700">
{label}
</label>
)}
<motion.input
ref={e => {
ref(e);
if (reference) {
if (typeof reference === 'function') {
reference(e);
} else {
reference.current = e;
}
}
}}
variants={variants.input}
initial="initial"
whileTap="active"
className={cn(
'border-solid border-2 border-gray-400 w-full rounded-md h-12 px-4 dark:bg-neutral-800 dark:placeholder-neutral-500 placeholder-neutral-400',
disabled && 'text-neutral-300 dark:text-neutral-600',
customError && 'border-solid border-2 border-red-400 outline-none',
className,
)}
disabled={disabled}
{...props}
{...rest}
/>
<AnimatePresence initial>
{(errors[name]?.message || customError) && (
<motion.div
initial={{ opacity: 0, y: -10 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -10 }}
transition={{ duration: 0.2 }}
className="text-xs text-red-500 dark:text-red-400">
{errors[name]?.message?.toString() || customError}
</motion.div>
)}
</AnimatePresence>
</div>
);
});
Input.displayName = 'Input';
Form.Input = Input;
// passord는 비밀번호를 볼 수 잇도록 구성하였습니다.
const Password = forwardRef<HTMLInputElement, InputSubComponents>(function (
{ name = 'password', ...props },
ref,
) {
const [type, setType] = useState<'password' | 'text'>('password');
const onClick = () => {
setType(prev => (prev === 'password' ? 'text' : 'password'));
};
const absolute = 'absolute top-4 right-6 cursor-pointer fill-gray-400';
return (
<div className="relative w-full">
<Input ref={ref} type={type} name={name} {...props} />
{type === 'password' ? (
<BsEyeFill onClick={onClick} className={absolute} />
) : (
<BsEyeSlashFill onClick={onClick} className={absolute} />
)}
</div>
);
});
Password.displayName = 'Password';
Form.Password = Password;
// Button 활성화 여부를 formState에 맞추어 handling 하고 있으며, 기존의 button 컴포넌트를 활용하여 확장하고 있습니다.
Form.Button = function ButtonComponent({
className,
children,
disabled = false,
variant = 'bottom',
isLoading,
}: ButtonProps) {
const { register, formState } = useFormContext();
const { isSubmitting, isValid, isDirty } = formState;
return (
<Button
type="submit"
isLoading={isLoading}
variant={variant}
disabled={!isValid || !isDirty || isSubmitting || disabled}
className={className}
{...register('submit')}>
{children}
</Button>
);
};
Reference
- React Hook Form?
- useForm
- - useForm을 활용한 에러 관리하기
- - useForm mode
- - formState
- React hook form과 유효성 라이브러리 연결하기
- Custom Form, Input 만들기
- Reference