input들로 구성된 form을 만들 때는 기본적으로 각 input의 상태값을 관리해줘야하는데, 내가 이번에 구현한 페이지에서는 select까지 포함해서 input이 총 46개가 들어갔다.
useState를 사용한다면 각 input에 1:1로 대응되도록 state를 만들지 않고 객체 형태로 state로 관리한다고 해도 복잡한 건 마찬가지이다. 더불어 서버에 보내기 전 데이터 가공과 유효성 검사까지 진행하려면 꽤나 지겨워진다. 그래서 폼 관리를 간편하게 만드는 라이브러리인 React Hook Form을 통해 폼을 구현했다.
- 간결하고 간편한 사용: React Hook Form은 코드를 간결하게 작성할 수 있도록 설계되어 있다. 폼 로직을 단순화하고 관리하기 쉽게 만들어준다.
- 렌더링 최적화: React Hook Form은 렌더링을 최적화하기 위해 사용자 입력에 대한 리렌더링을 최소화한다. 이는 성능 향상에 도움이 된다.
- 상태 관리: React Hook Form은 내부적으로 폼 상태를 관리하므로, 별도의 상태 관리 라이브러리를 사용하지 않아도 된다.
- 다양한 입력 유효성 검사 지원: 다양한 유효성 검사를 수행할 수 있으며, Yup, Joi, zod 등과 통합하여 사용할 수 있다.
- 커스텀 훅 및 컴포넌트와 통합: React Hook Form은 커스텀 훅과 컴포넌트와 쉽게 통합된다. 컴포넌트 간의 재사용성을 향상시키고 코드를 모듈화할 수 있다.
- 서버로의 데이터 전송: 폼 데이터를 서버로 쉽게 전송할 수 있다.
나는 타입스크립트와 함께 사용했고 공식문서에서 기본적인 사용법을 확인할 수 있다.
https://www.react-hook-form.com/ts/
import React from "react";
import { useForm, SubmitHandler } from "react-hook-form";
type FormValues = {
firstName: string;
lastName: string;
email: string;
};
export default function App() {
const { register, handleSubmit } = useForm<FormValues>();
const onSubmit: SubmitHandler<FormValues> = data => console.log(data);
return (
<form onSubmit={handleSubmit(onSubmit)}>
<input {...register("firstName")} />
<input {...register("lastName")} />
<input type="email" {...register("email")} />
<input type="submit" />
</form>
);
}
register 메서드를 통해 input을 훅폼에 등록한다. register는 객체를 반환하기 때문에 스프레드 문법을 사용해서 넣어줘야하고, register의 인자로 input name을 넣어줘야한다.
위 예제에서는 확인할 수 없지만 register를 등록할 때
<input {...register(inputName)} />
inputName을 string 그 자체로 넣지 않는 이상 정확히 미리 정의한 formValue타입과 일치하지 않는다면 타입오류가 발생하기 때문에 꼼꼼하게 타입을 지정해줘야한다.
Input Name -> Submit Result
register("firstName") -> {firstName: 'value'}
register("name.firstName") -> {name: { firstName: 'value' }}
register("name.firstName.0") -> {name: { firstName: [ 'value' ] }}
<input {...register(inputName as keyof InputTypes, {
특히 formValue 객체의 뎁스가 깊고, map을 돌리면서 변수에 일부 조작하여 inputName을 넣어줘야하여 타입을 지정하기 어려운 상황이라면 위와 같이 타입 단언을 사용할 수 있다. (타입 단언은 TypeScript 컴파일러에게 해당 값이 원하는 타입임을 알려주는 데 사용된다.)
하지만 타입 단언을 사용하면 컴파일러의 타입 검사를 우회하게 되므로 잘못된 타입 단언은 런타임 오류를 유발할 수 있다. 때문에 타입 정의를 개선하거나 제네릭 타입을 활용하여 타입 안전성을 확보할 수 있는지 먼저 검토하고 필요한 경우에 사용해야 한다.
장점에서 설명했듯이 훅폼의 메서드를 통해 간단하게 validation을 적용할 수 있고 원하는 처리를 간단하게 해줄 수 있다.
import * as React from "react"
import { useForm } from "react-hook-form"
export default function App() {
const { register, handleSubmit, resetField, } = useForm<FormValues>();
const handleClick = () => resetField("firstName")
return (
<form>
<input {...register("firstName", { required: true })} />
<button type="button" onClick={handleClick}>
Reset
</button>
</form>
)
}
간단하게 resetField 메서드로 예를 들자면 말 그래도 field를 리셋해준다. 나는 등록된 이미지를 삭제할 때 사용했다.
https://react-hook-form.com/docs/useform
이외에도 공식문서에서 원하는 메서드를 쉽게 찾아볼 수 있다.
https://react-hook-form.com/docs/useform/register#options
특히 validation과 관련된 부분은 register option 부분에서 상세히 설명되어있다.
하나의 테이블에 input이 8개가 들어가는 테이블이 있었다. 그래서 각 input마다 error message를 표기하는 것은 ui상 적절하지 않았고, 그렇다고 submit 콜백함수에서 모든 필드를 수동으로 체크하는 것도 비효율 적이라고 판단했다. 무엇보다 8개의 input 중 하나라도 error가 있을 시 실시간으로 error message가 표시되도록 관리하고자 했다. 하지만 errors는 FormState의 객체로된 프로퍼티이기 때문에 useEffect를 통해 실시간 상태 변경이 불가능하다. 내부 값은 변경되어도 객체 그 자체는 불변하기 때문.
errors가 속한 FormState를 useEffect의 의존성 배열에 넣는 것이다. 단점은 errors외에 다른 FromState의 프로퍼티들이 변경될 때에도 상태변경이 일어난다. 하지만 콘솔 찍으며 계속 사용해본 결과 실제 에러가 발생했을 때 거의 상태변경이 일어나지 않았기 때문에 딱히 신경쓰일만한 정도는 아니었다. 이로써 테이블 formState에 상태변경이 생겼을 때 해당 input들에 error가 있는지 확인하고 에러메시지 state를 설정하는 방식으로 진행했다.
페북 다니는 개발자님이 옛날옛적 나와 같은 고민을 issued에 남겨주셨었었당 덕분에 해결!
https://github.com/react-hook-form/react-hook-form/issues/3455
const Table: React.FC<TableProps> = ({ tableResource, register, formState }) => {
const [tableErrorMessage, setTableErrorMessage] = useState("");
const { title, subtitle, theadList, tbody, unit, registerName, required } =
tableResource;
const { errors } = formState;
//테이블 전체의 에러를 하나의 state로 관리
useEffect(() => {
for (let name of registerName) {
const tableName = name.split(".")[0];
const error = errors[tableName as keyof InputTypes];
if (error) {
setTableErrorMessage("필수 입력란을 작성해주세요.");
break;
}
setTableErrorMessage("");
}
}, [formState]);
validation을 걸어놨는데 한번 submit을 하기 전까지는 실시간으로 에러메시지가 노출이 되지 않았다. 한번 submit후에야 즉각적으로 에러체킹이 되었다.
onChange모드로 설정하면 처음부터 value가 change 될 때마다 실시간 validation이 이루어진다.
const FormUploader: React.FC<FormUploaderProps> = () => {
const { handleSubmit, watch, register, formState, resetField } = useForm<InputTypes>({
mode: "onChange", //실시간 validation을 위해 onChange 모드 설정
});
required - 공란으로 제출시 설정한 에러메시지를 errors.{fieldname}.message에서 가져와 활용할 수 있다.
pattern - 정규식을 통해 validation을 진행할 수 있다. 이 친구도 에러메시지 설정 가능하다.
valueAsNumber - input은 기본적으로 string으로 value가 저장된다. type이 number인 경우 이 속성을 사용하여 number타입으로 변환하여 저장할 수 있다.
setValueAs - 임의로 입력값을 조작하여 저장할 수 있다.
valueAsNumber가 계속 이상했다. boolean 값을 받는 친구인데, boolean 쓰면 에러 뜨고!! 공식문서에서도 원인은 못찾았지만 내가 예상하는 원인은 pattern과 함께 못쓰는 것 같다. pattern이랑 같이 쓰면 둘 중에 하나가 에러남!
내가 구현하고자 했던 것은 정규식을 활용한 validation과 number type으로 변환이었다. 위에 썼듯이 pattern과 valueAsNumber은 동시에 사용이 안되니 valueAsNumber은 대신 setValueAs를 이용했다. value를 인자로 받고 원하는 처리를 한 후 저장할 값을 return 하면 된다.
<input
id={id}
type={type}
className={twMerge(
"block w-full h-[44px] p-[10px] border border-gray-300 rounded-lg bg-gray-50 text-[12px] text-gray-900 outline-none",
errors[id] && "border-red-400",
)}
placeholder={placeholder}
{...register(id, {
required: required && "필수 입력란을 작성해주세요.",
pattern: {
value: validationRule || /.*/,
message: "입력 형식이 올바르지 않습니다.",
},
setValueAs: (value) => handleSetValue(value, type),
})}
/>