3. React.js : React Hook Form
리액트에서 폼을 관리하는 법
특정 앱을 제작하다보면 form 을 통해 사용자 입력을 받아야 하는 경우가 잦다. 간단한 로그인, 회원가입 부터 글, 댓글, 사용자 정보, 구매 등 사실 전부 form 을 사용한다.
리액트에서는 보통 폼을 관리할 때 useState를 통해 값을 저장하고 폼과 연결해서 사용한다.
function registerScreen() {
const [email, setEmail] = useState("");
return (
<div>
<form>
<input
type="email"
value={email}
onChange={e => setEmail(e.target.value)}
placeholder="이메일을 입력해주세요"
/>
</form>
</div>
)
}
이런 식으로 이메일의 state를 선언해놓고 사용할거다.
그냥 굉장히 정석적인 방법인데, 기능이 점점 많아질 수록 골치 아파지는 경우가 있다.
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [passwordConfirm, setPasswordConfirm] = useState("");
const [nickname, setNickname] = useState("");
const [phoneNumber, setPhoneNumber] = useState("");
// UI 상태 관리 (추가로 4개...)
const [isLoading, setIsLoading] = useState(false);
const [isError, setIsError] = useState(false);
const [errorMessage, setErrorMessage] = useState("");
const [isSuccess, setIsSuccess] = useState(false);
// 핸들러 함수들도 줄줄이 비엔나...
const handleEmail = (e) => setEmail(e.target.value);
const handlePassword = (e) => setPassword(e.target.value);
그냥 변수 선언으로 스파게티 코드가 되어버린다. 심지어 state는 바뀔 때 마다 리렌더링을 하기 때문에 위와 같은 경우에는 얼마나 많은 리렌더링이 일어날 지 알 수가 없다. 이건 분명 앱의 성능과 직결되는 문제다.
물론 객체로 관리할 수도 있다.
// 이렇게 생긴 것을
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [nickname, setNickname] = useState("");
const [phoneNumber, setPhoneNumber] = useState("");
const [formData, setFormData] = useState({
email: "",
password : "",
nickname : "",
phoneNumber : "",
})
// 이렇게 리팩토링 가능하다.
객체를 하나 선언하고, 모든 입력을 처리하는 함수를 만들면, 리렌더링 횟수가 꽤 줄어들 것이다. 그런데 각 값들의 유효성을 검증하는 로직이 많아질 수록 코드가 그냥 주구장창이 된다.
물론, 각 로직을 잘 분리하고 잘 관리하면 괜찮겠지만 세상사가 내 맘대로 흘러가지는 않는다.
그래서 주로 많이 사용하는 라이브러리가 리액트 훅 폼이다.
React Hook Form
function SignupForm() {
const { register, handleSubmit, formState: { errors } } = useForm();
return (
<form onSubmit={handleSubmit(data => console.log(data))}>
<input
{...register("email", {
required: "이메일은 필수입니다",
pattern: {
value: /\S+@\S+\.\S+/,
message: "이메일 형식이 올바르지 않습니다"
}
})}
/>
{errors.email && <p>{errors.email.message}</p>}
{/* 나머지 입력 필드들... */}
</form>
);
}
이 코드 하나에 이메일 값 관리와, 유효성 검증, 오류메시지를 띄우는 로직이 다 들어있다. 무려 state를 하나도 선언하지 않고 말이다.
이름부터 훅 폼이듯이, Form 관리를 위해 최적화된 라이브러리이다. 불필요한 렌더링도 줄여주며, TS와도 완벽히 호환된다고 한다.
사용 방법
먼저 리액트 훅 폼을 설치해준다.
npm install react-hook-form
TS 에서 사용하는 법을 간단히 알아보자. 아래는 공식 사이트의 예시 코드이다.
import { useForm, SubmitHandler } from "react-hook-form"
type Inputs = {
example: string
exampleRequired: string
}
export default function App() {
const {
register,
handleSubmit,
watch,
formState: { errors },
} = useForm<Inputs>()
const onSubmit: SubmitHandler<Inputs> = (data) => console.log(data)
console.log(watch("example")) // watch input value by passing the name of i
return (
<form onSubmit={handleSubmit(onSubmit)}>
<input defaultValue="test" {...register("example")} />
<input {...register("exampleRequired", { required: true })} />
{errors.exampleRequired && <span>This field is required</span>}
<input type="submit" />
</form>
)
}
한 번에 다 보기엔 좀 복잡하니 하나 씩 뜯어헤쳐보자.
useForm() 의 반환값
type Inputs = {
example: string
exampleRequired: string
}
여기선 당연히 input으로 받을 값들의 타입을 지정해준다.
const {
register,
handleSubmit,
watch,
formState: { errors },
} = useForm<Inputs>()
useForm은 여러 가지 값들을 반환해준다.
const {
register, // 입력 필드 등록
handleSubmit, // 폼 제출 처리
watch, // 필드 값 감시
formState, // 폼의 전반적인 상태
setValue, // 필드 값 설정
reset, // 폼 초기화
setError, // 에러 설정
clearErrors, // 에러 초기화
getValues // 현재 필드 값 가져오기
} = useForm();
몇 개를 간략하게 알아보자.
register
register는 입력 필드를 React-Hook-Form에 등록하는 함수이다. name, 과 options 파라미터를 받는다.
register(
name: string, // 필드 이름
options?: {...} // 선택적 옵션들
)
음 쉽게 말하면 input 태그랑 리액트 훅 폼을 연결 시켜주는 기능을 제공한다.
const { register } = useForm();
<input {...register("email")} />
// 위 코드는 내부적으로 아래의 의미를 가진다.
<input
name="email"
onChange={(e) => /* 값 변경 처리 */}
onBlur={(e) => /* focus 잃을 때 처리 */}
ref={/* 입력 요소 참조 */}
/>
한 줄로 편하게 사용할 수가 있다.
여러가지 옵션들도 넣어줄 수가 있는데,
// 1. 그냥 이름만 넣을 때,
<input {...register("email")} />
// 2. 여러 옵션들을 넣을 때,
<input {...register("email", {
옵션이름 : 옵션 값
})}>
이렇게 사용한다. 옵션들을 한 번 흝어보자.
// 필수 값 여부
required: true, // 또는 메시지를 표기하고싶으면 아래처럼
required: "에러 메시지"
// 최소 길이
minLength: 5, // 또는
minLength: {
value: 5,
message: "5글자 이상 입력하세요"
}
// 최대 길이
maxLength: {
value: 50,
message: "50글자 이하로 입력하세요"
},
// 정규식 패턴
pattern: {
value: /\S+@\S+\.\S+/,
message: "이메일 형식이 아닙니다"
},
// 커스텀 유효성 검사
validate: {
isAdmin: (value) => value !== "admin" || "사용할 수 없는 값입니다",
isAvailable: async (value) => {
const response = await checkAvailability(value);
return response.available || "이미 사용중입니다";
}
},
대게 유효성 검사하는 옵션들이다. 값이 많아지면 따로 객체로 떼내어 관리하는게 더 깔끔하고 좋을 것이다.
handleSubmit
handleSubmit 은 제출 시 어떤 동작을 할 지 다루는 함수다.
// 제출 시 실행할 콜백함수 정의(출력)
const onSubmit: SubmitHandler<Inputs> = (data) => console.log(data);
<form onSubmit={handleSubmit(onSubmit)}>
해당 코드는 제출 시, input 내의 유효성 검사를 다 통과했다면 내부의 콜백함수를 실행시켜 주는 역할을 한다. 검증 실패 시, onSubmit 을 실행하지 않고, formState.errors 에 에러 내용을 업데이트 해준다.
formState : { error }
errors 는 폼의 에러 상태를 관리하는 객체다. options 로 정한 규칙이 지켜지지 않았을 때 사용된다.
// 값 입력이 필수인 input 생성
<input {...register("exampleRequired", { required: true })} />
// 에러가 발생(유효성 통과x) 시 객체 생성
{errors.exampleRequired && <span>This field is required</span>}
만약 exampleRequired 라는 이름의 인풋에서 유효성 검증을 통과했다면 해당 객체는 {} 로 빈 객체가 된다. Falsy 한 값이므로 위의 span 태그는 생성되지 않는다.
하지만 통과하지 못한다면 다음과 같은 객체가 생성된다.
// errors 객체의 상태
{
exampleRequired: {
type: "required",
message: "",
ref: <input ... />
}
}
Truthy 하므로 위의 코드에서는 <span>This field is required</span> 가 반환될 것이다.
아래와 같은 방식으로도 오류 메시지를 띄워줄 수 있다.
<input {...register("email", { required: "이메일 주소를 확인해주세요." })} />
// errors.email이 존재할 때만 message를 출력함
{errors.email && <span>{errors.email.message}</span>}
registers 에서 넣어주었던 options 내의 message를 통과하지 못한 유효성 검증의 유형에 따라 유연하게 보여줄 수 있다.
watch
watch 는 input 내부의 값을 실시간으로 감시하는 역할을 한다.
<input {...register("example")} />
console.log(watch("example"))
이렇게 사용하면 example 이라는 이름의 input 값이 변경될 때 마다 콘솔에 출력해줄 것이다. 잘못 사용하면 리렌더링이 자주 발생할 수 있으니 조심히 사용하자.
useForm의 옵션들
위에서는 useForm 이 반환해주는 값들을 알아봤는데, 이번엔 useForm() 에서 줄 수 있는 옵션들에 대해 알아보자.
defaultValues
폼이 처음 렌더링 될 때, 해당 폼 내부의 디폴트 값을 설정해준다.
const { register } = useForm({
defaultValues: {
email: "",
password: "",
saveId: true // 체크박스 등도 가능
}
});
이렇게 하면 일일히 각 input 태그에 디폴트 값을 설정해주지 않아도 되고, 전반적인 폼의 구조를 파악하기 용이해진다. 한 눈에 봐도 어떤 인풋들이 있는 지 알 수 있다.
mode
mode 는 유효성검증을 어느 시점에 실시할 지를 결정한다.
const { register, formState: { errors } } = useForm({
mode: 'onSubmit' // 기본 , 제출시에만 (Submit 전까지는 에러 검사 안함)
// 또는
mode: 'onChange' // 값이 변경될 때마다
// 또는
mode: 'onBlur' // focus를 잃을 때
// 또는
mode: 'onTouched' // 한번 focus를 잃은 후부터는 onChange처럼
// 또는
mode: 'all' // onBlur와 onChange 모두
});
form 의 종류에 따라 실시간으로 확인해야하는 것이 있고, 그렇지 않은 것도 있을 것이다. 상황에 따라 유연하게 mode를 바꾸면 되겠다.
다양한 Input Type에서도 사용이 가능하다
text 타입 말고도, password, checkbox, radio, select, textarea 등 다양하게 사용 가능하다.
아직 부족하다 더 깊게 들어가보자.
FormProvider
각 입력창에 들어가야 하는 정보가 많아지거나, 재사용할 일이 많으면 각 입력창을 컴포넌트로 쪼개어 따로 관리하는 경우가 잦다. 매 컴포넌트 마다 useForm 을 선언하는건 매우 비효율적이고 사용의도와 맞지 않다. 심한 경우에는 props drilling 이 발생할 것이다.
이럴 때는 FormProvider 를 통해 useForm 반환값들을 전역적으로 뿌려주어야 한다.
FormProvider 는 역시 Context API 를 기반으로 사용하기에 하위 컴포넌트에서는 useFormContext를 이용하면 된다.
먼저 FormProvider 를 임포트한다.
import { useForm, FormProvider } from 'react-hook-form';
methods 는 useForm을 통해 반환된 객체이며 내부에 {register, handleSubmit} 등 과 같은 옵션을 담고 있다.
const methods = useForm();
이제 하위 컴포넌트에게 뿌려줄 차례이다, Spread Operator 를 이용해 methods 를 해체한 후 뿌려준다.
<FormProvider {...methods}>
<하위컴포넌트/>
</FormProvider>
그럼 이제 하위 컴포넌트는 useForm 의 반환값들을 사용할 수 있다.
import { useFormContext } from 'react-hook-form';
useFormContext를 임포트한 후,
const {register, handleSubmit} = useFormContext();
<form onSubmit={handleSubmit(onSubmit)}>
<input {...register("example")} />
</form>
이런 식으로 불러와서 destructuring 해준 후 해당 값들을 사용하면 된다!
React Native 에서 사용하는 법
React Native 에서도 폼 관리를 원활하게 하기 위해 RHF를 사용하는데, 웹이 아닌 앱이기 때문에 사용 방법이 약간 다르다.
간단히 말하면 register 속성을 사용하지 않고, Controller 라는 컴포넌트와 control 이라는 속성을 사용해야 한다. 자세히 알아보자.
import { Text, View, TextInput, Button, Alert } from "react-native"
import { useForm, Controller } from "react-hook-form"
export default function App() {
const {
control,
handleSubmit,
formState: { errors },
} = useForm({
defaultValues: {
firstName: "",
lastName: "",
},
})
const onSubmit = (data) => console.log(data)
return (
<View>
<Controller
control={control}
rules={{
required: true,
}}
render={({ field: { onChange, onBlur, value } }) => (
<TextInput
placeholder="First name"
onBlur={onBlur}
onChangeText={onChange}
value={value}
/>
)}
name="firstName"
/>
{errors.firstName && <Text>This is required.</Text>}
<Controller
control={control}
rules={{
maxLength: 100,
}}
render={({ field: { onChange, onBlur, value } }) => (
<TextInput
placeholder="Last name"
onBlur={onBlur}
onChangeText={onChange}
value={value}
/>
)}
name="lastName"
/>
<Button title="Submit" onPress={handleSubmit(onSubmit)} />
</View>
)
}
이는 공식 홈페이지에서 권장하는 사용 방법이다.
control, Controller
const {
control,
handleSubmit,
formState: { errors },
} = useForm({
defaultValues: {
firstName: "",
lastName: "",
},
})
잘 보면 register 자리에 control 이 들어와 있다.
이는 역시 register 처럼 인풋과 RHF를 연결해주는 역할을 한다.
그러나 Input 태그에 직접 연결하지 않는다.
Controller라는 컴포넌트에 연결해야한다. 그럼 이건 뭘까?
사실 왜 쓰는 지에 대한 자세한 이유는 웹과 앱의 차이를 알아야 하기 때문에 그냥 이렇게 써야 하는구나 라고 받아들이는게 정신 건강에 더 좋은 것 같다.
import { useForm, Controller } from "react-hook-form"
<Controller
name="example" // 컨트롤러의 Key 값을 정해준다.
control={control} // RHF 와 컨트롤러를 연결한다.
rules={{ // RN에서는 rules 로 유효성 검사를 한다.
required: true,
maxLength: 100
}}
render={({field : {onChange, onBlur, value}}) => (
<TextInput/> // UI를 넣으면 된다
)}
/>
name
고유한 key 값이다. 이것을 통해 각 인풋을 구분한다.
control
RHF와 컨트롤러를 연결시켜준다.
rules
register의 options 에 해당하는 값들을 넣을 수 있다. 유효성 검사를 한다.
render
이게 좀 복잡한데. 일단 하나의 객체를 파라미터로 받는다. 해당 객체 내에는 또 3개의 객체가 들어있다.
render = ({field, fieldState, formState}) => <인풋 컴포넌트/>
field 는 다음과 같은 값들을 지닌다,
field : {
value, // 현재 폼에 저장된 값 value={value} 로 연결
onChange, // 값이 바뀔 때 마다 호출할 함수 onChangeText에 연결
onBlur, // 입력창의 포커스가 나갔을 때 호출
ref // 엘리먼트의 참조값, 포커스를 줄 때 사용
}
fieldState 는 다음과 같은 값들을 지닌다.
fieldState : {
invalid, // 현재 값이 유효성 검사를 통과하지 못했는지 여부 boolean
error, // 발생한 에러 객체
isDirty // 사용자가 한 번이라도 값을 수정했는지 여부
}
나머지 하나는 필요할 때 찾아도 될 것 같은데, 아무튼 이와 같은 값들을 제공해주니 인풋과 연결할 때 써먹으면 된다.
render={({ field: { onChange, onBlur, value }, fieldState: { error } }) => (
<View>
<TextInput
value={value} // 1. 상태 주입 (Data Binding)
onChangeText={onChange} // 2. 이벤트 매핑 (RN의 텍스트 변경을 RHF에 알림)
onBlur={onBlur} // 3. 상태 감지 (Touch 상태 업데이트)
style={{ borderColor: error ? 'red' : 'black' }} // 4. 조건부 스타일링
/>
{error && <Text>{error.message}</Text>} // 에러 발생 시 보여줄 텍스트
</View>
)}
이런 식으로 사용할 수 있겠다. 크게 다르지 않은 것 같으면서도 조금 헷갈리는 부분이 있을테니, 사용할 때 잘 찾아보고 사용하면 되겠다.
라이브러리 하나 배우는 것도 참 쉽지 않은 일이다.
2026년 3월 28일 AM 8:58
로그인 필요