오늘은 제가 전직장, 현직장에서도 유용하게 썼던 라이브러리 React Hook Form을 소개해보려고 합니다 !
Form 처리 다들 어떻게 하시나요 ?
간단하게 이메일과 비밀번호를 받는 회원가입 페이지를 만들어볼게요.
function SignUpPage() {
return (
<form>
<input placeholder="이메일" />
<input placeholder="비밀번호" type="password" />
<button type="submit">회원가입</button>
</form>
);
}
export default SignUpPage;
좋습니다 ! 하지만 이렇게만 하면 저 이메일 input과 비밀번호 input의 value를 가져올 수 없습니다.useState
를 추가해서 값을 받아오게 처리해보겠습니다.
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를 통해서 서버로 보내면 되겠죠 ?
submit
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;
이제 회원가입이 끝일까요 ? 아쉽게도 아직 추가해야할것들이 남아 있어요.
validation
- 이메일은 필수 입력이다.
- 빈값이면
이메일은 필수 입력입니다.
라는 에러 메세지를 표시해야한다.
- 빈값이면
- 이메일 양식에 맞아야 한다
- 맞지 않으면
이메일 양식이 아닙니다.
라는 에러 메세지를 표시해야한다.
- 맞지 않으면
- 비밀번호는 필수 입력이다.
- 빈값이면
비밀번호는 필수 입력입니다.
라는 에러 메세지를 표시해야한다.
- 빈값이면
- 비밀번호 양식에 맞아야 한다
- 영어 + 숫자 최소 각각 한글자 이상, 6글자 이상 9글자 이하
- 맞지 않으면
비밀번호는 영어 + 숫자 최소 각각 한글자 이상, 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
가 동작하면서 불필요한 리렌더링이 발생하네요. 성능상으로 좋아보이지는 않네요. 이것을 비제어 컴포넌트
를 사용하여 한번 수정을 해볼게요
useRef로 처리하기
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관련된 처리를 추상화 시켜서 커스텀 훅을 만들면 좀 더 가독성도 좋아지고 재사용도 가능하지 않을까요 ?
useForm 커스텀 훅 간단하게 구현
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
을 사용하면
- ref를 통한 렌더링 최적화
- 다양한 validation 처리 (required, 정규식)
- 상황에 맞는 에러 메세지 처리
- 현재 모든 form들이 validation을 통과 했는지 체크
이런 작업들을 직접 구현하지 않아도 할 수 있네요. 그리고
- defaultValue 적용
- onChange option으로 받아 따로 추가하고 싶은 onChange 추가
이런 기능들도 제공을 해요.
이 useForm 부족한점 🤔🤔
이 useForm
를 모든 form에 사용하면 정말 좋겠지만 아쉽게도 여러 부족한점이 보이네요.
- ref를 받을 수 없는 필드들은 어떻게 처리 해야하는지
- 페이지가 구조가 복잡해지면 저 useForm에서 return된 함수들을 전부props으로 내려야 함
- required, pattern (정규식) 말고 다른 validation 대응이 안됨
- 각각 필드들을 reset할 수 없음
- 필요에 의해서 강제 리렌더링이 불가능함
- 에러 상태를 clear 할 수 없음
이런 점들을 직접 하나씩 전부 구현하려면 번거로울거 같아요. 이런 처리가 전부되어 있는 라이브러리가 있다면 정말 유용할거 같아요.
React Hook 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의 다양한 기능들
이제 React Hook Form의 다양한 기능들에 대해서 살펴볼건데요. 다는 못보고 제가 많이 쓰는 기능들 위주로 해보겠습니다.
useForm
위에서 사용한 useForm인데요.
파라미터로
- mode: 어떤 이벤트때 유효성 검사를 할것인지 결정
- values : 서버의 데이터와 sync 맞출때 사용
이외에도 많은 옵션들이 있습니다 !
return되는 값으로는
- register : ref값을 넘겨서 form을 처리, validation 처리
- reset, resetField : 값을 reset
- formState : form의 에러 상태, 폼과 상호작용에 대한 정보
- handleSubmit : submit할때 사용하는 함수, 유효성 검사가 통과 안되면 실행 안됨
- getValues : 현재 form의 value를 가져옴
이외에도 많은 api들이 있습니다 !
useController
이 훅은 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;
이런식으로 사용할 수 있습니다.
useFormContext
이 훅을 사용하면 폼 처리관련해서 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을 처리하는데 좋은 하나의 선택지로 사용됐으면 좋겠다는 생각이 드네요. 긴글 읽어주셔서 감사합니다 🙂
'frontend' 카테고리의 다른 글
??? : 추상화 잘합니다. (대충 할 말 빙빙 돌려 말한다는 뜻) (0) | 2024.04.01 |
---|---|
번들러 없이 개발하다 머리깨진썰 푼다. (부제: 번들러에 대해서 공부해보자) (1) | 2024.03.26 |
React Suspense 알아보기 (1) | 2024.03.25 |
SSR 진짜 CSR보다 빠를까 ? (1) | 2024.03.25 |
비전공자 6개월 독학 23살 신입 프론트엔드 취업 회고 (2) | 2024.03.25 |