-
React-hook-form 이란?React-hook-form 2024. 11. 12. 22:29
이 글은 지금까지 사용했던 라이브러리 중 당연 최고라 생각하는 리엑트훅폼의 사용후기를 공유하고싶은 마음에 쓰게 된 글입니다.
이게 뭡니까?
한 마디로 정리하면 Form 관리를 위한 강력한 도구라고 말할 수 있다. 혹시 아래와 같은 상황을 겪었거나, 상대를 당황시킬 예정이라면 최고의 선택이 될 수 있다.
물론 극단적인 예시이다. (보통 많은 관심사나, 쓰임, 목적에 알맞는 상태들은 하나의 객체로 다룬다.)
많은 상태를 한번에 전부 다룬다는 것은 효과적이지 않다.
왜 효과적이지 않을까?
렌더링 때문이다.
렌더링은 쉽게말하면 지금 보여지고있는 모니터의 화면을 어떻게 구성할지, 설명할지 요청하는 프로세스이며, 함수의 실행이다.
function Counter() { // 음~ 무엇을 그릴까요 const [count, setCount] = useState(0); return ( // 이런 모양으로 그려주겠니? <div> <h1>현재 숫자: {count}</h1> <button onClick={() => setCount(count + 1)}> +1 </button> </div> ); }
Counter( ) 라는 함수가 실행하면서, 화면에 무엇을 그릴지 설명하는 과정이 시작되고, return문에서는 조금 더 명확하게 react에게 ‘이런 모양으로 그려주세요’ 라고 부탁한다.
현재 숫자는 0이지만, 버튼을 누르면 기존의 count에 1을 더하게되면서 현재 숫자의 값이 바뀐다. 그러면, 바뀐 값을 화면에 다시 보여줘야하기 떄문에 리엑트는 화면을 다시 그린다.(리렌더링) 버튼을 클릭하는 행위나, 키보드를 치는 일이나 똑같다고 생각하며, 그대로 가져와서 프로젝트에 적용시키는 상상을 해보자.
지금 회원가입을 위하여 사용자의 값을 받으려고 4개의 input과 각각의 상태가 따로 존재한다. 이메일이 20자, 닉네임은 2자, 비밀번호는 8자, 라고 가정하고 사용자가 오타없이 완벽하게 작성을 했더라도 총, 30번의 리렌더링이 발생하게 된다. 무려 30번이나 화면이 다시그려진다.
"그러면 객체로 관리하면 되잖아"
사실 그렇다. 할말이 없다.
const [email, setEmail] = useState(""); // 상태 1 const [nickname, setNickname] = useState(""); // 상태 2 const [password, setPassword] = useState(""); // 상태 3 const [confirmPw, setConfirmPw] = useState(""); // 상태 4 // 리팩토링 const [formData, setFormData] = useState({ email: "", nickname: "", password: "", confirmPw: "" });
객체로 관리하고 모든 입력을 처리하는 하나의 함수를 선언하면, 리렌더링이 아주 효과적으로 줄어들 것이다.
그런데 프로젝트에서는 이렇게 단순한 폼만 다루지 않고 다음의 고민들이 생겨나기 시작한다.
"이메일 형식은 어떻게 검증해?"
"비밀번호와 비밀번호 확인이 어떻게 같은데?"
"닉네임이 2글자 이상이야?"
"모든 필드 다 채워졌어?"
"에러 메시지 어떻게 처리할건데?"
"이거 수정된거야 안된거야?"
"서버로 전송하기전에 어떻게 한번 더 검증할건데?"
머리가 뜨거워지기 시작한다. 하나둘 추가하며 다잡아보려 하지만 생각치도 못하게 맞물리는 오류와 몇백 줄을 돌파하는 코드를 보면 잠에들고싶어진다.
물론 해결은해야지이 모든 고통을 겪은 사람에게 추천해줄 만한 라이브러리가 바로 리엑트 훅 폼이다.
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> ); }
놀랍게도 이 안에, 이메일 input이 다 채워져있는지, 잘못된 값을 하나라도 넣었는지, 오류메시지는 어떤걸 띄울지 전부 들어가있다. → '얼랄라?' 상태와 상태를 관리하는 함수들이 보이지 않는다.
그렇다 단 몇줄의 코드로 해결할 수 있게된다.
리액트 훅 폼
리엑트 훅 폼은 Form관리를 위한 라이브러리다. 최소한의 코드로 복잡한 폼을 쉽게 만들고, 성능까지 신경쓸 수 있도록 도와준다. 그러니까 불필요한 리렌더링을 최소화하기도하며, Ts와도 완벽하게 호환된다.
적은 코드로 강력한 기능, 반복적인 보일러플레이트 제거, 불필요한 리렌더링 방지, 내장된 다양한 검증로직 및 커스텀로직 등이 있지만 피부로 와닿는 것은 아무래도 제어 컴포넌트와 비제어 컴포넌트의 장점만 골고루 살린 것이다.
제어 컴포넌트와 비제어 컴포넌트??
function ControlledForm() { const [value, setValue] = useState(""); return ( <input value={value} onChange={(e) => setValue(e.target.value)} /> ); }
제어 컴포넌트는 사용자의 입력을 기반하여 state를 관리하고 업데이트한다. 즉, 즉각적인 입력 값 검증을 하며, 실시간으로 UI요소 업데이트를 하고, 입력 값의 형식을 지정한다. 하지만 단점으로는, 매 입력마다 리렌더링이 발생하고, 상태관리를 위한 코드가 필요하다.
반면에
function UncontrolledForm() { const inputRef = useRef(); const handleSubmit = () => { console.log(inputRef.current.value); }; return ( <input ref={inputRef} defaultValue="" /> ); }
비 제어 컴포넌트는 ref를 통하여 값을 얻으며 실시간으로 동기화 되지않는다. 그에 따라 불필요한 리렌더링이 없고 간단하고 더 나은 성능의 이점이 존재한다. 하지만 단점으로는 실시간 입력 값 검증이 어렵고, 동적인 UI업데이트 제한이 있다. 또한 실시간으로 값의 파악이 어려우니, 데이터 핸들링이 더 복잡할 수도 있다.
(++useRef는 heap영역에 저장되는 일반적인 자바스크립트 객체)
즉 상대의 단점이 자신의 장점이고, 자신의 장점이 상대의 단점이 되는 완벽한 상극이다.
이러한 상황에서 리엑트 훅 폼은 각각의 장점들만 뽑아 사용가능하게 도와준다.
설치 방법
npm install react-hook-form # 또는 yarn add react-hook-form
예시와 함께 리액트 훅 폼을 이해해 보자.
import { useForm } from "react-hook-form"; function LoginForm() { const { register, // 입력 필드 등록 handleSubmit, // 폼 제출 처리 formState: { errors } // 폼 상태 (에러 등) } = useForm(); const onSubmit = (data) => { console.log(data); // 또는 Form제출 로직 (email과 pwd가 담긴다) }; return ( <form onSubmit={handleSubmit(onSubmit)}> <div> <label>이메일</label> <input {...register("email", { required: "이메일은 필수입니다", pattern: { value: /\S+@\S+\.\S+/, message: "이메일 형식이 아닙니다" } })} /> {errors.email && <p>{errors.email.message}</p>} </div> <div> <label>비밀번호</label> <input type="password" {...register("password", { required: "비밀번호는 필수입니다", minLength: { value: 8, message: "비밀번호는 8자 이상이어야 합니다" } })} /> {errors.password && <p>{errors.password.message}</p>} </div> <button type="submit">로그인</button> </form> ); }
이 예시는 React-hook-form으로 구현한 간단한 로그인 Form의 구현이다.
아래는 useForm이 반환하는 것이다.
const { register, // 입력 필드 등록 handleSubmit, // 폼 제출 처리 watch, // 필드 값 감시 formState, // 폼의 전반적인 상태 setValue, // 필드 값 설정 reset, // 폼 초기화 setError, // 에러 설정 clearErrors, // 에러 초기화 getValues // 현재 필드 값 가져오기 } = useForm();
전부 다루기보다는 필수적인 것들을 다뤄보겠다.
register
register는 입력 필드를 React-Hook-Form에 등록하는 함수이고, 두 개의 매개 변수를 받는데, name과 options을 받는다.
register( name: string, // 필드 이름 options?: {...} // 선택적 옵션들 )
한마디로 각 입력필드를 추적하고 관리할 수 있게 해주는 기능이라고 보면된다.
const { register } = useForm(); <input {...register("email")} /> // 위 코드는 내부적으로 아래의 의미를 가진다. <input name="email" onChange={(e) => /* 값 변경 처리 */} onBlur={(e) => /* focus 잃을 때 처리 */} ref={/* 입력 요소 참조 */} />
options
options에는 다음과 같은 값들이 들어 갈 수 있다.
// 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: "이메일 형식이 아닙니다" }, // 최소/최대 값 (숫자 입력 시) min: { value: 0, message: "0 이상의 값을 입력하세요" }, max: { value: 100, message: "100 이하의 값을 입력하세요" }, // 커스텀 유효성 검사 validate: { isAdmin: (value) => value !== "admin" || "사용할 수 없는 값입니다", isAvailable: async (value) => { const response = await checkAvailability(value); return response.available || "이미 사용중입니다"; } }, // 의존성 있는 유효성 검사 validate: (value, formValues) => { return value === formValues.password || "비밀번호가 일치하지 않습니다" } })} />
message는 규칙을 어겼을 때 표시할 에러이다. (그리고 자주 사용되는 option은 보통은 객체화 하여 관리한다.)
handleSubmit
handleSubmit은 이 Form이 제출할때 어떤 동작을 할지 다루는 함수이다.
이게 다다.
formState
formState: { errors } , formState의 errors는 폼의 에러 상태를 관리하는 객체이다. option들로 지정한 규칙들이 지켜지지 않았을 때를 위해 사용되는 값이다.
{error.type === 'required' && '필수 입력입니다'} {error.type === 'minLength' && '길이가 너무 짧습니다'} {error.type === 'pattern' && '형식이 올바르지 않습니다'} {error.type === 'validate' && error.message}
어떠한 규칙을 어겼는지, 그리고 그 규칙을 어기면 보여질 메시지가 무엇인지를 나타내고 다룰 수 있다.
또한 formState에는 error말고도,
const { formState: { errors, // 각 필드의 에러 상태 isDirty, // 폼이 수정되었는지 dirtyFields, // 어떤 필드가 수정되었는지 touchedFields, // 어떤 필드가 터치되었는지 isSubmitting, // 제출 중인지 isSubmitted, // 제출되었는지 isSubmitSuccessful, // 제출 성공했는지 isValid, // 유효성 검사 통과했는지 submitCount // 제출 시도 횟수 } } = useForm();
수정을 감지한다던지, 제출 중인지, 되었는지를 다룰 수 도 있다.
사용자가 작성된 글을 수정했는지 안했는지 또는, 제출중일 때 사용자의 엑션을 제한할 수 있는 등의 기능을 추가적으로도 구현할 수 있다.
아니 근데 제어랑 비제어는 왜 언급했나요??
그럼 여기서 당연하게 드는 고민은 error massage가 언제 어떻게 화면에 나타낼지 고민하게 된다.
loginForm을 구현해봤다면, 에러메시지를 다루는것에 대한 고민은 한번 쯤 해보았을 것이다.
(제어) : 사용자가 입력하는 순간 바로 유효성 검사가 돌아가서 에러메시지를 띄우고 border에 빨간 테두리를 입힌다? 이것은 사용자 경험에 좋지않다.
(비제어) : 그렇다고 사용자가 버튼(트리거)를 눌렀을때만 검사를 동작하여 에러메시지를 다룬다? 이건 또 올바른 값을 입력하더라도 에러메시지가 그대로 화면에 노출 되어 있으니 모양새가 보기 좋지않다.
이 고민은 처음 공부를 시작했을 때 꽤 오랫동안 고민했던 문제였다.
(이때가 처음으로 디바운스와 스로틀을 알게된 시점)이 고민을 useForm의 mode 옵션과 함께 설명하고싶다.
const { register, formState: { errors } } = useForm({ mode: 'onSubmit' // 기본 , 제출시에만 (★Submit 전까지는 에러 검사 안함★) // 또는 mode: 'onChange' // 값이 변경될 때마다 // 또는 mode: 'onBlur' // focus를 잃을 때 // 또는 mode: 'onTouched' // 한번 focus를 잃은 후부터는 onChange처럼 // 또는 mode: 'all' // onBlur와 onChange 모두 });
mode를 생략하면 onSubmit이다.
useForm의 mode는 다음과 같다. onSubmit과 onChagne만 봐도 무엇이 제어의 느낌인지 비 제어의 느낌이 올 것이다.
Form종류에 따라 실시간으로 확인해야되는 값들이 존재할 것이고, 그렇지 않은 Form이 있을 수 있다. 그럴땐 상황에 맞추어서 mode를 변경해가면서 사용해도 된다.
그런데 하나의 Form안에서 비제어의 장점을 사용하고싶은데 특정한 딱 한 필드만 값을 실시간으로 다루고 싶다? 그럴때 useForm이 반환하는 watch가 존재한다.
예를 들어, 비밀번호와 비밀번호 확인이 존재할 때, 비밀번호 확인의 value를 실시간 감지하고 싶다면 아래처럼 사용하면된다.
// 예시를 위한 useForm 및 return 문 간소화 function PasswordForm() { const { register, watch } = useForm(); // watch // 비밀번호 값을 감시 const password = watch("password"); return ( <form> <input type="password" {...register("password")} /> <input type="password" {...register("passwordConfirm", { validate: (value) => value === password || "비밀번호가 일치하지 않습니다" })} /> </form> ); }
'watch' 인자로는 감지하고 싶은 input의 name을 넘겨준다.
여기서 코드의 동작은, 두번 째 input의 passwordConfirm에서 현재 입력값인 value와 감시하고있는 password을 실시간으로 비교한다. 물론 watch가 호출 될 때마다 해당 컴포넌트는 리렌더링이 된다. 하지만 적어도 해당 필드의 값만 변경시에만 리렌더링이 될뿐, 다른 필드때문에 전체가 리렌더링 되는 성능상 이슈는 감소하게 된다.
(++이 이상의 최적화를 다룬다면 실시간으로 다룰 값을 특정 컴포넌트로 분리하여, 폼 컨텍스트를 사용해도 좋다. useFormContext는 본문과 거리가 멀어 생략)
이렇게 제어 컴포넌트의 장점과 비제어의 장점을 살려서 불필요한 리렌더링은 감소시키고, 필요한 경우에만 동적으로 감지하여 ui 업데이트를 가능하게 할 수 있다는 점이다.
자 여기까지 됬다면 이제는 수많은 상태를 관리할 필요도, 복잡한 유효성 검사 로직을 코드에서 걷어낼 수 있게 된다.
++다양한 입력 타입에서 사용가능
function SignupForm() { const { register } = useForm(); return ( <form> {/* 1. 텍스트 입력 */} <input {...register("username")} /> {/* 2. 비밀번호 */} <input type="password" {...register("password")} /> {/* 3. 체크박스 */} <input type="checkbox" {...register("agree")} /> {/* 4. 라디오 버튼 */} <input type="radio" value="male" {...register("gender")} /> <input type="radio" value="female" {...register("gender")} /> {/* 5. 셀렉트 박스 */} <select {...register("country")}> <option value="">선택하세요</option> <option value="kr">한국</option> <option value="us">미국</option> </select> {/* 6. 텍스트영역 */} <textarea {...register("description")} /> </form> ); }
끝으로
만약 사용한적이 없었더라면 강력하게 추천하고싶은 라이브러리이다. 실제로 사용했던 라이브러리 중 가장 만족도가 좋았다. 리뷰 이벤트가 없지만 리뷰를 하고 싶었던 그런 라이브러이이다. 이 다음은 input을 공통컴포넌화 시키고, validation을 객체화시켜서 잘 관리하는 것과, 여러 컴포넌트들과 상태를 공유하기 위한 useFormContext를 사용하는 일만 남았을 것이다.