react-hook-form을 재사용 가능하게 만들고 싶어서, 이리저리 레퍼런스를 찾아보았지만 내 입맛에 맞는 것은 없었다. 그래서 수많은 삽질 후, 결국 만들어냈다. react-hook-form.. 너무 힘들었다 증말
react-hook-form의 기본 사용법
기존, react-hook-form에 있는 docs를 참고해보면 아래와 같이 쓸수도 있을 것이다. 하지만, 사용자입력값을 받아와야하는 폼들은 사용해야될 곳이 굉장히 많다. 매번 사용할 때마다 아래와 같이 입력하는 것은 참 번거롭다.
import { useForm } from "react-hook-form";
export default function App() {
const {
register,
handleSubmit,
formState: { errors }
} = useForm();
function onSubmit(state) {
console.log("formState", state);
}
return (
<form onSubmit={handleSubmit(onSubmit)}>
<input {...register("name", { required: true, maxLength: 5 })} />
<input
{...register("age", {
required: true,
maxLength: 3,
pattern: /^(0|[1-9]\d*)(\.\d+)?$/
})}
/>
<input
placeholder="aaa@aaa.com"
{...register("email", {
required: true,
maxLength: 10,
pattern: /^\S+@\S+$/i
})}
/>
<button onClick={handleSubmit(onSubmit)}>입력</button>
</form>
);
}
1차 시도
상위에서 register을 선언하고 받아와서 사용하기 위해 register과 options를 props로 정의했고, 나머지 input의 props들을 사용하기 위해 extends 해주었다.
errorMessage는 react-hook-form 공식문서에서 사용되는 hookform/error-message 패키지를 이용해서 만들었다.
import React from 'react';
import { DeepMap, FieldError, Path, RegisterOptions, UseFormRegister } from 'react-hook-form';
import { ErrorMessage } from '@hookform/error-message';
export interface RHFInputTypes<TFieldValues> extends React.InputHTMLAttributes<HTMLInputElement> {
register: UseFormRegister<TFieldValues>;
options?: RegisterOptions;
name: Path<TFieldValues>;
errors?: Partial<DeepMap<TFieldValues, FieldError>>;
helperClassName?: string; //helperText의 className
}
//기본적으로 input의 모든 속성들을 사용할 수 있습니다.
//외부로부터 TFieldValues에 들어갈 타입을 정의해야합니다.
//ex) type TFieldValues = { foo : string };
export default function RHFInput<TFieldValues>({ register, options, name, errors = {}, helperClassName = '', ...props }: RHFInputTypes<TFieldValues>) {
return (
<div>
<input {...(register && register(name, options))} name={name} {...props} />
<ErrorMessage
errors={errors}
name={name as any}
render={({ message }) => <p className={`text-warn text-[13px] ${helperClassName}`}>{message}</p>}
/>
</div>
);
}
RHFInput을 스타일링 하기 위해 LoginInput을 만들었고, LoginInput을 LoginForm 컴포넌트에서 사용한다.
LoginForm 컴포넌트에서는 register을 선언하고, 하위 컴포넌트에게 넘겨준다.
form에 사용될 컬럼값은 LoginLabel 이며, 각 인풋에 사용될 값들은 enum으로 정의해주거나, object로 한데 모아서 정의해거나 했다.
type TFieldValues = {
email: string;
password: string;
};
enum LoginLabel {
email = '이메일',
password = '비밀번호',
}
enum LoginPlaceHolder {
email = '이메일을 입력해주세요.',
password = '패스워드를 입력해주세요.',
}
function LoginForm() {
const { register } = useForm<TFieldValues>();
return (
<form className="flex flex-col w-full">
<LoginInput register={register} name="email" />
<LoginInput register={register} name="password" />
<button className="bg-blue1 w-full h-[50px] rounded-full text-white mt-[30px] mb-[10px]">이메일로 로그인하기</button>
</form>
);
}
type LoginInputProps = {
register: UseFormRegister<TFieldValues>;
name: Path<TFieldValues>;
};
function LoginInput({ register, name }: LoginInputProps) {
return (
<div>
<span className=" inline-block text-[#888] mt-[17px] mb-[7px]">{LoginLabel[name]}</span>
<RHFInput
register={register}
name={name}
className="w-full h-[50px] px-[12px] text-[#333] border border-[#e1e2e3] rounded placeholder:text-[#e1e2e3]"
placeholder={LoginPlaceHolder[name]}
/>
</div>
);
}
위까지 보고났다면, 일단 불편한게 한 두가지가 아니다. 중첩을 많이한다면 props 지옥에 빠질 것이고, input에 사용될 타입추론도 안되서 일일이 확인해봐야한다. 💩 그래서 2차 시도를 한다.
2차 시도
이번에는 helperText도 넣어보고 input 옆에 들어갈 컴포넌트 자리도 마련해뒀다. 무시해도 된다.
register props driliing을 피하기 위해서 FormProvider와 useFormContext를 쓸 것이다.
InputProps는 사용하는 곳에서 써야하니 export한다.
import { ErrorMessage } from '@hookform/error-message';
import { ReactNode } from 'react';
import { useFormContext, RegisterOptions } from 'react-hook-form';
export type InputProps = {
id: string;
label?: string;
placeholder?: string;
helperText?: string;
type?: string;
readOnly?: boolean;
validation?: RegisterOptions;
helperClassName?: string;
errorClassName?: string;
inputRightComponent?: ReactNode;
} & React.ComponentPropsWithoutRef<'input'>;
export default function Input({
id,
label,
placeholder = '',
helperText,
type = 'text',
readOnly = false,
validation,
className,
helperClassName,
errorClassName,
inputRightComponent,
...rest
}: InputProps) {
const {
register,
formState: { errors },
} = useFormContext();
function getClassName(): string {
let result = `w-full px-[12px] h-[50px] rounded-[5px] outline-none text-[16px] ${className}`;
if (readOnly) result += ' ' + 'bg-[#f2f4f7] cursor-not-allowed ';
else result += ' ' + 'text-[#333] border border-[#e1e2e3] focus:border-blue1';
return result;
}
return (
<div>
{label ? (
<div className="mb-[7px] mt-[17px] ">
<label className="font-semi-bold text-[#888] text-[14px]" htmlFor={id}>
{label}
</label>
</div>
) : null}
<div className="flex items-center mb-[8px]">
<input
{...register(id, validation)}
{...rest}
type={type}
name={id}
id={id}
readOnly={readOnly}
className={getClassName()}
placeholder={placeholder}
/>
{inputRightComponent ?? null}
</div>
<ErrorMessage errors={errors} name={id} render={({ message }) => <p className={`text-warn text-[13px] mb-[8px] ${errorClassName}`}>{message}</p>} />
<div>{helperText && <p className={`text-[#888] mb-[10px] text-[13px] ${helperClassName}`}>{helperText}</p>}</div>
</div>
);
}
상위컴포넌트에서는 이렇게 쓴다. form의 각 column들을 FieldNames에서 선언하고, fieldOptions는 각 컬럼들을 기반으로 해서 Input의 Props들을 정리하는 곳이다. InputProps는 위 Input에 선언한 타입이다.
이렇게 사용하면 fieldOptions에서 누락된 컬럼명도 바로 알 수 있고, 어떤 값들을 넣을 수 있는지 바로바로 타입추론이 되기때문에 작업이 편해진다.
또한 중첩된 컴포넌트에서도 props drilling을 피할 수 있으니 얼마나 좋은가 Form Provider, useFormContext 최고다..
enum FieldNames {
//form에서 사용할 column들을 등록합니다.
email = 'email',
password = 'password',
}
const fieldOptions: { [key in FieldNames]: InputProps } = {
//각 input에 들어갈 props들을 정의합니다.
email: {
id: 'email',
label: '이메일',
validation: {
required: '입력하세요!',
},
},
password: {
id: 'password',
label: '패스워드',
validation: {
required: '입력하세요!',
},
},
};
function LoginForm() {
const form = useForm();
function onSubmit(foo: any) {
console.log(foo);
}
return (
<FormProvider {...form}>
<form className="flex flex-col w-full" onSubmit={form.handleSubmit(onSubmit)}>
<NestedComponent/>
<button className="bg-blue1 w-full h-[50px] rounded-full text-white mt-[30px] mb-[10px]">이메일로 로그인하기</button>
</form>
</FormProvider>
);
}
function NestedComponent() {
return (
<>
<Input {...fieldOptions.email} />
<Input {...fieldOptions.password} />
</>
);
}
'💻 코드공방' 카테고리의 다른 글
onChange 시에 바로바로 validation 해주는 input 만들기 (0) | 2021.11.03 |
---|---|
url이 바뀌면 메뉴 버튼 닫기 (0) | 2021.08.09 |
인풋체크박스를 활용하여 필터링검색 구현하기 (0) | 2021.07.21 |