
오늘은 제가 전직장, 현직장에서도 유용하게 썼던 라이브러리 React Hook Form을 소개해보려고 합니다 !
간단하게 이메일과 비밀번호를 받는 회원가입 페이지를 만들어볼게요.
function SignUpPage() {
return (
<form>
<input placeholder="이메일" />
<input placeholder="비밀번호" type="password" />
<button type="submit">회원가입</button>
</form>
);
}
export default SignUpPage;
좋습니다 ! 하지만 이렇게만 하면 저 이메일 input과 비밀번호 input의 value를 가져올 수 없습니다.
useState를 추가해서 값을 받아오게 처리해보겠습니다.
import { useState } from "react";
function SignUpPage() {
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
return (
<form>
<input
placeholder="이메일"
value={email}
onChange={(e) => setEmail(e.target.value)}
/>
<input
placeholder="비밀번호"
value={password}
onChange={(e) => setPassword(e.target.value)}
type="password"
/>
<button type="submit">회원가입</button>
</form>
);
}
export default SignUpPage;
이제는 이메일 input과 비밀번호 input의 value를 가져올 수 있겠네요 ! 그리고 이제 이것을 signUp api를 통해서 서버로 보내면 되겠죠 ?
import { FormEvent, useState } from "react";
import { signUp } from "./api/signUp";
function SignUpPage() {
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const handleSubmit = async (e: FormEvent<HTMLFormElement>) => {
e.preventDefault();
await signUp({ email, password });
};
return (
<form onSubmit={handleSubmit}>
<input
placeholder="이메일"
value={email}
onChange={(e) => setEmail(e.target.value)}
/>
<input
placeholder="비밀번호"
value={password}
onChange={(e) => setPassword(e.target.value)}
type="password"
/>
<button type="submit">회원가입</button>
</form>
);
}
export default SignUpPage;
이제 회원가입이 끝일까요 ? 아쉽게도 아직 추가해야할것들이 남아 있어요.
이메일은 필수 입력입니다.라는 에러 메세지를 표시해야한다.이메일 양식이 아닙니다. 라는 에러 메세지를 표시해야한다.비밀번호는 필수 입력입니다.라는 에러 메세지를 표시해야한다.비밀번호는 영어 + 숫자 최소 각각 한글자 이상, 6글자 이상 9글자 이하 입니다. 라는 에러 메세지를 표시해야한다.복잡한 조건들이네요 ! 각각 폼 필드에 대한 에러 처리에 대한 값이 추가되야 할거 같네요.
import { ChangeEvent, FormEvent, useState } from "react";
import { signUp } from "./api/signUp";
const EMAIL_REGEX = /^[a-zA-Z0-9._-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,6}$/;
const PASSWORD_REGEX = /^(?=.*[A-Za-z])(?=.*\d)[A-Za-z\d]{6,9}$/;
const ERROR_MESSAGE = {
email: {
required: "이메일은 필수 입력입니다.",
invalid: "이메일 양식이 아닙니다.",
},
password: {
required: "비밀번호는 필수 입력입니다.",
invalid:
"비밀번호는 영어 + 숫자 최소 각각 한글자 이상, 6글자 이상 9글자 이하 입니다.",
},
};
function SignUpPage() {
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [emailErrorMessage, setEmailErrorMessage] = useState("");
const [passwordErrorMessage, setPasswordErrorMessage] = useState("");
const disabledSubmit = !(
!emailErrorMessage &&
!passwordErrorMessage &&
!!email &&
!!password
);
const handleSubmit = async (e: FormEvent<HTMLFormElement>) => {
e.preventDefault();
await signUp({ email, password });
};
const handleEmailChange = (e: ChangeEvent<HTMLInputElement>) => {
const { value } = e.target;
setEmail(value);
if (value === "") {
setEmailErrorMessage(ERROR_MESSAGE.email.required);
return;
}
if (!EMAIL_REGEX.test(value)) {
setEmailErrorMessage(ERROR_MESSAGE.email.invalid);
return;
}
setEmailErrorMessage("");
};
const handlePasswordChange = (e: ChangeEvent<HTMLInputElement>) => {
const { value } = e.target;
setPassword(value);
if (value === "") {
setPasswordErrorMessage(ERROR_MESSAGE.password.required);
return;
}
if (!PASSWORD_REGEX.test(value)) {
setPasswordErrorMessage(ERROR_MESSAGE.password.invalid);
return;
}
setPasswordErrorMessage("");
};
return (
<form onSubmit={handleSubmit}>
<input placeholder="이메일" value={email} onChange={handleEmailChange} />
{!!emailErrorMessage && <span role="alert">{emailErrorMessage}</span>}
<input
placeholder="비밀번호"
value={password}
onChange={handlePasswordChange}
type="password"
/>
{!!passwordErrorMessage && (
<span role="alert">{passwordErrorMessage}</span>
)}
<button disabled={disabledSubmit} type="submit">
회원가입
</button>
</form>
);
}
export default SignUpPage;
흠...🤔🤔🤔🤔 뭔가 코드를 추상화 시킬 수 있을거 같은대요. 우선 화면부터 볼까요 ?

input에 입력을 할때마다 setState가 동작하면서 불필요한 리렌더링이 발생하네요. 성능상으로 좋아보이지는 않네요. 이것을 비제어 컴포넌트를 사용하여 한번 수정을 해볼게요
import { ChangeEvent, FormEvent, useRef, useState } from "react";
import { signUp } from "./api/signUp";
const EMAIL_REGEX = /^[a-zA-Z0-9._-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,6}$/;
const PASSWORD_REGEX = /^(?=.*[A-Za-z])(?=.*\d)[A-Za-z\d]{6,9}$/;
const ERROR_MESSAGE = {
email: {
required: "이메일은 필수 입력입니다.",
invalid: "이메일 양식이 아닙니다.",
},
password: {
required: "비밀번호는 필수 입력입니다.",
invalid:
"비밀번호는 영어 + 숫자 최소 각각 한글자 이상, 6글자 이상 9글자 이하 입니다.",
},
};
function SignUpPage() {
const emailInputRef = useRef<HTMLInputElement>(null);
const passwordInputRef = useRef<HTMLInputElement>(null);
const [emailErrorMessage, setEmailErrorMessage] = useState("");
const [passwordErrorMessage, setPasswordErrorMessage] = useState("");
const disabledSubmit = !(
!emailErrorMessage &&
!passwordErrorMessage &&
!!emailInputRef.current?.value &&
!!passwordInputRef.current?.value
);
const handleSubmit = async (e: FormEvent<HTMLFormElement>) => {
e.preventDefault();
const email = emailInputRef.current?.value || "";
const password = passwordInputRef.current?.value || "";
await signUp({ email, password });
};
const handleEmailChange = (e: ChangeEvent<HTMLInputElement>) => {
const { value } = e.target;
if (value === "") {
setEmailErrorMessage(ERROR_MESSAGE.email.required);
return;
}
if (!EMAIL_REGEX.test(value)) {
setEmailErrorMessage(ERROR_MESSAGE.email.invalid);
return;
}
setEmailErrorMessage("");
};
const handlePasswordChange = (e: ChangeEvent<HTMLInputElement>) => {
const { value } = e.target;
if (value === "") {
setPasswordErrorMessage(ERROR_MESSAGE.password.required);
return;
}
if (!PASSWORD_REGEX.test(value)) {
setPasswordErrorMessage(ERROR_MESSAGE.password.invalid);
return;
}
setPasswordErrorMessage("");
};
return (
<form onSubmit={handleSubmit}>
<input
ref={emailInputRef}
placeholder="이메일"
onChange={handleEmailChange}
/>
{!!emailErrorMessage && <span role="alert">{emailErrorMessage}</span>}
<input
placeholder="비밀번호"
onChange={handlePasswordChange}
type="password"
ref={passwordInputRef}
/>
{!!passwordErrorMessage && (
<span role="alert">{passwordErrorMessage}</span>
)}
<button disabled={disabledSubmit} type="submit">
회원가입
</button>
</form>
);
}
export default SignUpPage;
이렇게 useRef를 넣어서 수정해봤어요. 결과를 살펴볼까요 ??

이제 불필요한 리렌더링이 발생하지 않고 렌더링이 필요한 타이밍에만 발생하네요 ! 좋습니다.
근데 아직 코드 자체의 퀄리티가 좋아보이지는 않네요. 이 Form관련된 처리를 추상화 시켜서 커스텀 훅을 만들면 좀 더 가독성도 좋아지고 재사용도 가능하지 않을까요 ?
import { ChangeEvent, useRef, useState } from "react";
type UseFormProps<T extends string[]> = {
[key in T[number]]: {
defaultValue?: string;
onChange?: (e: ChangeEvent<HTMLInputElement>) => void;
validation?: {
pattern?: {
message: string;
value: RegExp;
};
required?: {
message: string;
value: boolean;
};
};
};
};
type FormState<T extends string[]> =
| { [key in T[number]]: string | undefined }
| undefined;
export const useForm = <T extends string[]>(props: UseFormProps<T>) => {
const ref = useRef<{ [key in T[number]]: HTMLInputElement }>({} as any);
const [formState, setFormState] = useState<FormState<T>>();
const isNotYetInSyncRef = Object.keys(ref.current).length === 0;
const initValue = Object.keys(props).reduce((acc, key) => {
acc[key as T[number]] = props[key as T[number]].defaultValue ?? "";
if (!acc) return acc;
return acc;
}, {} as { [key in T[number]]: string });
const getValues = () => {
if (isNotYetInSyncRef) return initValue;
const values = {} as { [key in T[number]]: string };
Object.keys(ref.current).forEach((_key) => {
const key = _key as T[number];
values[key] = ref.current[key].value;
});
return values;
};
const isAllVaild = Object.keys(getValues()).every((key: T[number]) => {
const value = getValues()[key];
const { validation } = props[key];
const { pattern, required } = validation || {};
if (required?.value && !value && !pattern?.value) return false;
if (pattern && !pattern.value.test(value)) return false;
return true;
});
const setFormStateTarget = (name: T[number], message: string) => {
setFormState((prev) => {
const newState = !prev
? ({ [name]: message } as FormState<T>)
: { ...prev, [name]: message };
return newState;
});
};
const register = (name: T[number]) => {
const setRef = (el: HTMLInputElement) => {
ref.current[name] = el;
};
const onChange = (e: ChangeEvent<HTMLInputElement>) => {
const required = props[name].validation?.required;
const pattern = props[name].validation?.pattern;
const customOnChange = props[name].onChange;
const currentErrorMessage = formState?.[name];
const { message: requireErrorMessage, value: isRequired } =
required || {};
const { message: patternErrorMessage, value: patternRegex } =
pattern || {};
const { value } = e.target;
const isValidPattern = patternRegex?.test(value);
customOnChange?.(e);
// pattern 밸리데이션 처리
if (
currentErrorMessage !== patternErrorMessage &&
patternRegex &&
!isValidPattern
) {
setFormStateTarget(name, patternErrorMessage ?? "");
// required 밸리데이션 처리
} else if (isRequired && value === "") {
setFormStateTarget(name, requireErrorMessage ?? "");
// 밸리데이션 통과
} else if (
(patternRegex &&
currentErrorMessage === patternErrorMessage &&
(isValidPattern || !value)) ||
(isRequired &&
currentErrorMessage === requireErrorMessage &&
value !== "")
) {
setFormStateTarget(name, "");
}
};
return {
defaultValue: props[name].defaultValue,
onChange,
ref: setRef,
};
};
return { formState, getValues, isAllVaild, register };
};
이제 이 훅을 사용해볼까요 ?
import { FormEvent } from "react";
import { signUp } from "./api/signUp";
import { useForm } from "./useForm";
const EMAIL_REGEX = /^[a-zA-Z0-9._-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,6}$/;
const PASSWORD_REGEX = /^(?=.*[A-Za-z])(?=.*\d)[A-Za-z\d]{6,9}$/;
const ERROR_MESSAGE = {
email: {
required: "이메일은 필수 입력입니다.",
invalid: "이메일 양식이 아닙니다.",
},
password: {
required: "비밀번호는 필수 입력입니다.",
invalid:
"비밀번호는 영어 + 숫자 최소 각각 한글자 이상, 6글자 이상 9글자 이하 입니다.",
},
};
function SignUpPage() {
const { register, formState, getValues, isAllVaild } = useForm<
["email", "password"]
>({
email: {
validation: {
required: {
message: ERROR_MESSAGE.email.required,
value: true,
},
pattern: {
message: ERROR_MESSAGE.email.invalid,
value: EMAIL_REGEX,
},
},
},
password: {
validation: {
required: {
message: ERROR_MESSAGE.password.required,
value: true,
},
pattern: {
message: ERROR_MESSAGE.password.invalid,
value: PASSWORD_REGEX,
},
},
},
});
const { email: emailErrorMessage, password: passwordErrorMessage } =
formState || {};
const handleSubmit = async (e: FormEvent<HTMLFormElement>) => {
e.preventDefault();
const { email, password } = getValues();
await signUp({ email, password });
};
return (
<form onSubmit={handleSubmit}>
<input placeholder="이메일" {...register("email")} />
{!!emailErrorMessage && <span role="alert">{emailErrorMessage}</span>}
<input placeholder="비밀번호" type="password" {...register("password")} />
{!!passwordErrorMessage && (
<span role="alert">{passwordErrorMessage}</span>
)}
<button disabled={!isAllVaild} type="submit">
회원가입
</button>
</form>
);
}
export default SignUpPage;
이 만든 useForm을 사용하면
이런 작업들을 직접 구현하지 않아도 할 수 있네요. 그리고
이런 기능들도 제공을 해요.
이 useForm를 모든 form에 사용하면 정말 좋겠지만 아쉽게도 여러 부족한점이 보이네요.
이런 점들을 직접 하나씩 전부 구현하려면 번거로울거 같아요. 이런 처리가 전부되어 있는 라이브러리가 있다면 정말 유용할거 같아요.

React Hook Form은 위에 나온 모든 내용을 해결해주는 매우 유용한 라이브러리입니다.
한번 적용해서 확인해볼까요 ?
import { signUp } from "./api/signUp";
import { useForm } from "react-hook-form";
const EMAIL_REGEX = /^[a-zA-Z0-9._-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,6}$/;
const PASSWORD_REGEX = /^(?=.*[A-Za-z])(?=.*\d)[A-Za-z\d]{6,9}$/;
const ERROR_MESSAGE = {
email: {
required: "이메일은 필수 입력입니다.",
invalid: "이메일 양식이 아닙니다.",
},
password: {
required: "비밀번호는 필수 입력입니다.",
invalid:
"비밀번호는 영어 + 숫자 최소 각각 한글자 이상, 6글자 이상 9글자 이하 입니다.",
},
};
interface SignUpForm {
email: string;
password: string;
}
function SignUpPage() {
const { register, handleSubmit, formState } = useForm<SignUpForm>({
mode: "onChange",
});
const { errors, isValid } = formState;
const { email, password } = errors;
const onSubmit = handleSubmit(async (data: SignUpForm) => {
await signUp(data);
});
return (
<form onSubmit={onSubmit}>
<input
placeholder="이메일"
{...register("email", {
pattern: {
message: ERROR_MESSAGE.email.invalid,
value: EMAIL_REGEX,
},
required: {
message: ERROR_MESSAGE.email.required,
value: true,
},
})}
/>
{!!email && <span role="alert">{email.message}</span>}
<input
placeholder="비밀번호"
type="password"
{...register("password", {
required: {
message: ERROR_MESSAGE.password.required,
value: true,
},
pattern: {
message: ERROR_MESSAGE.password.invalid,
value: PASSWORD_REGEX,
},
})}
/>
{!!password && <span role="alert">{password.message}</span>}
<button disabled={!isValid} type="submit">
회원가입
</button>
</form>
);
}
export default SignUpPage;
괜찮아 보이나요 ? 제가 위에서 만든 커스텀 훅인 useForm과 비슷한 느낌도 있는데요. 사실 react hook form의 useForm을 보고 만들어서 그렇습니다 ㅎ
이제 React Hook Form의 다양한 기능들에 대해서 살펴볼건데요. 다는 못보고 제가 많이 쓰는 기능들 위주로 해보겠습니다.
위에서 사용한 useForm인데요.
파라미터로
이외에도 많은 옵션들이 있습니다 !
return되는 값으로는
이외에도 많은 api들이 있습니다 !
이 훅은 ref를 받을 수 없을때 주로 사용하는대요.
useForm, useFormContext에서 받은 name을 넣어야하고 control을 넣으면 useForm, useFormContext과 값을 동기화시킬 수 있습니다.
import { useController, useForm } from "react-hook-form";
import NoRefInput from "./NoRefInput";
interface Form {
test: string;
}
function UseController() {
const { control } = useForm<Form>({
mode: "onChange",
});
const { field, formState } = useController({
name: "test",
control,
rules: {
required: {
message: "This is required",
value: true,
},
validate: (value) => value !== "test",
},
});
return (
<>
<NoRefInput
onChange={(e) => field.onChange(e.target.value)}
value={field.value ?? ""}
/>
{!!formState.errors.test && (
<span role="alert">{formState.errors.test.message}</span>
)}
</>
);
}
export default UseController;
이런식으로 사용할 수 있습니다.
이 훅을 사용하면 폼 처리관련해서 props으로 내릴 필요가 없어집니다.
일을 하다보면 폼처리를 할때 각각의 필드가 복잡한 것들도 많이 보이는대요. 그때 컴포넌트를 따로 분리해서 처리를 할때가 있는데 그때 사용하면 매우 유용합니다.
import React from "react"
import { useForm, FormProvider, useFormContext } from "react-hook-form"
export default function App() {
const methods = useForm()
const onSubmit = (data) => console.log(data)
return (
<FormProvider {...methods}>
<form onSubmit={methods.handleSubmit(onSubmit)}>
<NestedInput />
<input type="submit" />
</form>
</FormProvider>
)
}
function NestedInput() {
const { register } = useFormContext()
return <input {...register("test")} />
}
어떠신가요 ?
저는 개발을 하면서 form 처리가 난이도가 있는 작업이라고 생각합니다. 구조가 복잡하면 복잡할수록 그 난이도도 같이 올라가는거 같다고 느껴졌습니다.
여러분들이 form을 처리하는데 좋은 하나의 선택지로 사용됐으면 좋겠다는 생각이 드네요. 긴글 읽어주셔서 감사합니다 🙂
useForm 커스텀 훅 간단하게 구현 에서 제네릭 부분이 너무 아찔하게 느껴졌네요,,