기존의 React Form 코드
import React, { useState } from "react";
export default function NormalForm() {
const [username, setUsername] = useState("");
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [formErrors, setFormErrors] = useState("");
const onUsernameChange = (event: React.SyntheticEvent<HTMLInputElement>) => {
const {
currentTarget: { value },
} = event;
setUsername(value);
};
const onEmailChange = (event: React.SyntheticEvent<HTMLInputElement>) => {
const {
currentTarget: { value },
} = event;
setEmail(value);
};
const onPasswordChange = (event: React.SyntheticEvent<HTMLInputElement>) => {
const {
currentTarget: { value },
} = event;
setPassword(value);
};
const onSubmit = (event: React.SyntheticEvent<HTMLFormElement>) => {
event.preventDefault();
console.log(username, email, password);
// FIXME: input 엘리먼트에 attribute 로 건 제약조건은 개발자모드에서 제거할 수 있어서 보안에 취약하다.
// TODO: 제출 전 추가적인 Validation 이 필요하다. ex) username is too short.
if (username.length < 6) {
setFormErrors("Username is too short!"); // how many errors are gonna happen?
}
// FIXME: 좋은 사용자 경험을 위해서 6글자 이상 username 이 입력된다면, UX 를 위해 즉시 경고메세지는 지워지게 만들자.
// -> 할 일이 너무 많다!
};
return (
<form onSubmit={onSubmit}>
<input
value={username}
onChange={onUsernameChange}
type="text"
placeholder="Username"
minLength={5}
required
/>
<input
value={email}
onChange={onEmailChange}
type="email"
placeholder="Email"
required
/>
<input
value={password}
onChange={onPasswordChange}
type="password"
placeholder="Password"
required
/>
<input type="submit" value="Create Account" />
</form>
);
}
문제 1: 보일러 플레이트 코드가 생기고, 코드가 길어진다.
onChange
에 들어갈 모든 콜백 함수를 각각 작성해주어야 한다.- 사실 내용은
React Hooks
의set...
메서드를 이용해 값을 설정하는 것 밖엔 없다. - HTML 코드에도 다 각각 연동시켜주어야 한다.
- 사실 내용은
문제 2: 검증을 하기 까다롭다.
검증은 보통 2가지로 나뉜다.
- HTML Attribute 에
required
,minLength
등을 입력하여 적용할 수 있는 마크업에서 자체적으로 지원하는 검증- 그러나 이 검증은 사용자가 개발자도구에서 Attribute 를 지워버리면 무효화된다. 그래서 아래의 검증이 추가적으로 필요하다.
- 간혹 브라우저가 지원하지 않는 경우도 있을 수 있다.
- Submit 전에 콜백 메서드에서 최종적으로 올바른 값이 들어왔는지 확인하는 검증
- HTML Attribute 로 값을 검증하는데는 한계가 있고, 클라이언트가 조작해버릴 수 있기 때문이다.
이 과정에서 또 매우 많은 코드를 작성하게 된다.
문제 3: UX 를 고려하면 코드가 더 길어진다.
값을 검증만 하면 안되고, 결국엔 사용자에게 검증 결과를 알려주어야 한다. 즉, 에러 메세지를 표출해주어야 한다. 이를테면, username
에 6글자 이하가 들어온 상태에서는 경고 메세지를 띄우고 있고, 6글자가 넘으면 즉시 경고 메세지가 사라지게 만든다면, 이로 인해 또 추가적인 코딩이 필요하게 된다.
문제 해결 1: 보일러 플레이트 코드 제거
import { useForm } from "react-hook-form";
// TODO: Less code (*)
// TODO: Better validation
// TODO: Better Errors (set, clear, display)
// TODO: Have control over inputs
// TODO: Don't deal with events (*)
// TODO: Easier inputs (*)
export default function ReactHookForm() {
const { register, watch } = useForm();
console.log(watch()); // 입력된 값을 실시간으로 지켜보는 역할
return (
<form>
<input
{...register("username")}
type="text"
placeholder="Username"
required
/>
<input {...register("email")} type="email" placeholder="Email" required />
<input
{...register("password")}
type="password"
placeholder="Password"
required
/>
<input type="submit" value="Create Account" />
</form>
);
}
useForm()
이 반환하는 register()
라는 메서드를 제공하여 문제를 해결한다.
register("username")
코드는 다음과 같은 4가지 반환 값을 갖게 된다.
{
name: "username",
onBlur: async (event) => {…},
onChange: async (event) => {…},
ref: (ref) => {…}
}
onBlur 이벤트 는 포커스가 사라졌을 때 발생하는 이벤트이다.
위와 같은 객체를 반환하는데, 각 프로퍼티는 HTML Input Element
에 Attribute 로서 들어갈 것들을 정의해놓은 것이다. 위의 jsx 예제 소스코드처럼 멋진 spread 문법과 jsx 문법을 통해 HTML 마크업에 적용될 수 있다.
<input
{...register("username")}
type="text"
placeholder="Username"
required
/>
결과적으로 코드는 적어졌지만, 코드가 하는 역할 자체는 이전에 작성했던 코드와 같은 역할을 한다.
공식 문서 를 통해 Register Fields 를 좀 더 자세히 살펴볼 수 있다.
문제 해결 2: Validation 에서 생기는 문제 해결
import { useForm } from "react-hook-form";
// TODO: Less code (*)
// TODO: Better validation -> 너무 큰 onSubmit 함수를 갖고 싶지 않다.
// TODO: Better Errors (set, clear, display)
// TODO: Have control over inputs
// TODO: Don't deal with events (*)
// TODO: Easier inputs (*)
export default function ReactHookForm() {
const { register, watch, handleSubmit } = useForm();
const onValid = () => {
console.log("I am valid baby!");
};
return (
<form onSubmit={handleSubmit(onValid)}>
<input
{...register("username", { required: true })}
type="text"
placeholder="Username"
/>
<input
{...register("email", { required: true })}
type="email"
placeholder="Email"
/>
<input
{...register("password", { required: true })}
type="password"
placeholder="Password"
/>
<input type="submit" value="Create Account" />
</form>
);
}
handleSubmit()
메서드를 통해, 검증 문제의 일부를 해결한다. 기존의 검증에서는 검증을 2번 해줘야 하는 문제가 있었다.
- HTML Input Attribute 를 이용한 검증
onSubmit
콜백 메서드에서의 검증
React Hook Form 에서는 {required: true}
와 handleSubmit()
이 반환한 콜백 메서드를 통해 이를 한번에 해결해준다. required
외에도 공식문서 를 보면 max
, min
, pattern
등 많은 검증을 지원한다. 검증에 실패하면 HTML 요소로 다시 포커싱을 하는 등의 간단한 부가기능도 같이 제공한다.
export declare type RegisterOptions<TFieldValues extends FieldValues = FieldValues, TFieldName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>> = Partial<{
required: Message | ValidationRule<boolean>;
min: ValidationRule<number | string>;
max: ValidationRule<number | string>;
maxLength: ValidationRule<number>;
minLength: ValidationRule<number>;
pattern: ValidationRule<RegExp>;
validate: Validate<FieldPathValue<TFieldValues, TFieldName>> | Record<string, Validate<FieldPathValue<TFieldValues, TFieldName>>>;
valueAsNumber: boolean;
valueAsDate: boolean;
value: FieldPathValue<TFieldValues, TFieldName>;
setValueAs: (value: any) => any;
shouldUnregister?: boolean;
onChange?: (event: any) => void;
onBlur?: (event: any) => void;
disabled: boolean;
deps: InternalFieldName | InternalFieldName[];
}>;
handleSubmit 메서드는 2가지 콜백 함수를 받는데, 성공 시 실행할 콜백과 실패 시 실행할 콜백을 받는다. 성공, 실패시 알맞은 콜백을 넣어주면 된다.
문제 해결 3: 필드 에러 메세지 표기 관련 문제 해결
import { FieldErrors, useForm } from "react-hook-form";
// TODO: Less code (*)
// TODO: Better validation (*)
// TODO: Better Errors (set, clear, display)
// TODO: Have control over inputs
// TODO: Don't deal with events (*)
// TODO: Easier inputs (*)
interface LoginForm {
username: string;
password: string;
email: string;
}
export default function ReactHookForm() {
// setValue, watch, clearError, setError
const {
register,
handleSubmit,
// 에러에 대한 정보를 가지고 있다.
formState: { errors },
} = useForm<LoginForm>({
// formState 로 에러에 대한 정보가 언제 갈지 결정한다.
// API 를 이용하여 해당 아이디가 중복인지 계속 체크하며 진행하는 것도 가능하다.
mode: "onChange",
defaultValues: {
email: "defaultemail@gmail.com",
},
});
const onValid = (data: LoginForm) => {
console.log("data", data);
};
const onInvalid = (errors: FieldErrors) => {
// console.log("errors", errors);
};
console.log("errors", errors);
return (
<form onSubmit={handleSubmit(onValid, onInvalid)}>
<input
{...register("username", {
required: "username is required",
minLength: {
message: "The username should be longer than 5 chars.",
value: 5,
},
})}
type="text"
placeholder="Username"
/>
<input
{...register("email", {
required: "email is required",
validate: {
notGmail: (value) =>
// API 를 통한 검증이든 뭐든 할 수 있다.
!value.includes("@gmail.com") || "Gmail is not allowed.",
},
})}
type="email"
placeholder="Email"
className={`${errors.email?.message ? `bg-red-100` : `bg-green-100`}`}
/>
{
// formState 에 있는 오브젝트를 통해 쉽게 에러를 UI 에 표기하는 것도 가능하다.
errors.email?.message
}
<input
{...register("password", { required: "password is required" })}
type="password"
placeholder="Password"
/>
<input type="submit" value="Create Account" />
</form>
);
}
LoginForm
타입스크립트 인터페이스를 만들어 우리가 넘길 값에 대한 청사진을 주었다.
useForm
메서드를 호출할 때,defaultValues
를 이용하면 기본 값도 줄 수 있다.
위와 같이 onInvalid
콜백 메서드에 인자를 받으면, 에러 시 에러가 난 필드명과 에러 메세지를 확인할 수 있다.
// errors
username: {type: 'minLength', message: 'The username should be longer than 5 chars.', ref: input}
이렇게 에러 타입과 에러 메세지가 담긴 객체를 반환해주어, 이를 에러 표기에 이용할 수 있다.
에러 메세지를 입력하는 법은 간단한데, required
와 같이 매개변수가 필요 없을 때는 보통 바로 메세지를 value
로 작성하면 되고, minLength
와 같이 매개변수가 필요할 때는 객체를 만드는 방식으로 작성하면 된다.
required: "username is required",
minLength: {
message: "The username should be longer than 5 chars.",
value: 5,
},
위와 같이 입력된 message
는 사용자의 입력이 유효하지 않을 때, formState.errors
객체 내부에 들어간다.
errors: {
email: {type: 'required', message: 'email is required', ref: input.bg-red-100},
password: {type: 'required', message: 'password is required', ref: input},
username: {type: 'required', message: 'username is required', ref: input}
}
위는 값이 유효하지 않을 때 errors
오브젝트 내부 필드에 들어있는 값이다. 값이 유효하다면 errors
객체는 어떠한 프로퍼티도 갖고 있지 않게 된다.
이렇게 errors
내부에 값이 들어간다는 것을 이용하면 다양한 방법으로 사용자에게 에러를 알릴 수 있따.
예제 코드 1: errors
상태에 따라 생겼다가 사라졌다 하는 HTML Element 를 만들기
{
// 이 텍스트는 `formState` 내부 `errors` 의 상태에 따라 표기될 수도 있고 표기되지 않을 수도 있다.
errors.email?.message
}
errors
내부 email
필드에 에러 메세지가 있다면, 위의 텍스트가 표기되고 없다면 표기되지 않을 것이다.
예제 코드 2: HTML Element 에 class
값을 줘서 디자인적으로 표현하기
<input
{...register("email", {
required: "email is required"
})}
type="email"
placeholder="Email"
className={`${errors.email?.message ? `bg-red-100` : `bg-green-100`}`}
/>
errors
내부 email
필드에 에러 메세지가 있다면, tailwind css 를 이용해 디자인을 바꿔주도록 설정했다.
예제 코드 3: 필드에 커스텀 검증 로직 만들기
<input
{...register("email", {
validate: {
notGmail: (value) =>
// API 를 통한 검증이든 뭐든 할 수 있다.
!value.includes("@gmail.com") || "Gmail is not allowed.",
},
})}
type="email"
placeholder="Email"
/>
위와 같이 validate
속성을 이용해 내부에 프로퍼티를 만들고, 프로퍼티의 메서드가 문자열 값을 반환하게만 만들면, 커스텀 검증을 할 수 있다. 문자열이 들어있다면, 검증에 실패한 것으로 보고 에러 메세지까지 같이 전달할 수 있는 형태가 된다.
문제 해결 4: 여러 필드에 걸친 에러나 모든 필드에 걸친 에러 처리하기
export default function ReactHookForm() {
const {
register,
handleSubmit,
// 에러에 대한 정보를 가지고 있다.
formState: { errors, isValid },
setError,
} = useForm<LoginForm>();
const onValid = (data: LoginForm) => {
console.log("data", data);
// 하나의 필드에서 발생한 에러가 아니라, 전체 필드에서 발생한 에러 혹은 API 검증 실패 등을 처리할 수 있다.
if(await isDuplicateAPI()) {
setError("username", {
message: "이미 존재하는 username 입니다.",
});
}
if (data.username === data.password) {
setError("password", {
message: "username 과 password 가 동일하면 안 됩니다.",
});
}
// 기타, 성별은 '남'에 체크했는데, 주민번호가 '2' 로 시작하는 경우 등 하나의 필드가 아닌 다수의 필드에 대한 검증이 가능하다.
// setError 가 지원하는 types 에서 MultipleFieldErrors 를 이용하면 된다.
};
}
onValid()
콜백에서 기본 단일 필드 검증을 통과한 데이터에 대해 API 등으로 혹은 여러 필드에 대한 검증을 여러번 더 거치게 만들 수 있다.
setError()
메서드를 통해 필드 에러를 직접 세팅할 수 있다. 아니면 인터페이스에 allFields
등 필드를 몇개 더 추가해서 그쪽으로 에러를 보내줘도 될 것 같다.
문제 해결 5: 다양한 유틸 메서드 (필드 초기화 등)
유틸 메서드들을 잘 활용하면 개발에서 귀찮았던 여러가지들을 쉽게 구성할 수 있다.
watch()
: Form 이 가지고 있는 데이터를 실시간으로 모니터링할 수 있다.reset()
: Form 에 입력된 값을 초기화할 수 있다.resetField()
: Form 의 특정 필드의 값을 초기화할 수 있다.formState
내부isSubmitSuccessful
: 성공적으로 submit 이 되었는지 알 수 있다.isValid
: 현재 유효한 값인지 알 수 있다.isDirty
: 입력된 값이 기본 값인지 체크한다.- formState 관련 공식 API 문서 에서 어떠한 값을 지원하는지 잘 나와있다.
유틸 메서드에 대해 더 잘 알고 싶다면, API 문서 를 잘 살펴보는 것이 도움이 될 것 같다.
문제 해결 6: 타입스크립트로 정적 타입 체크하기
interface EnterForm {
email?: string;
phone?: string;
}
const Enter: NextPage = () => {
const { register } = useForm<EnterForm>();
// ...
}
위와 같이 인터페이스를 정의 후에 useForm
의 제네릭 타입인 TFieldValues
로 설정해주면, react-hook-form
에서 사용하는 타입을 헷갈리지 않을 수 있다. 또한 intellisense 도 지원받을 수 있어서 더 많은 실수를 예방할 수 있다.
팁
팁 1: custom input 에 react hook form 적용시키기
아래와 같은 Input
커스텀 컴포넌트가 있다고 가정할 때,
<Input
{...register("email")}
name="email"
label="Email address"
type="email"
required
register={register("email")}
/>
위와 같이 Input
컴포넌트에 바로 {...register("email")}
을 주면, 결과적으로 이 attributes 가 어떤 태그에 바인딩되는지 알 수 없다. 이 attributes 는 primitive input 엘리먼트를 사용할 때와 동일하게 바인딩 되어야 한다. 그러기 위해서는 아래와 같이 register
를 따로 받은 후
export default function Input({
register,
label,
name,
kind = "text",
...rest
}: InputProps) {
<input
id={name}
{...rest}
{...register}
className="appearance-none w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm placeholder-gray-400 focus:outline-none focus:ring-orange-500 focus:border-orange-500"
/>
}
별개의 primitive input 에 spread operator 를 이용하여 위와 같이 뿌려주어야 한다. 결과적으로 prop 으로 useForm()
의 결과 자체를 가져와서 ...
spread operator 로 뿌려주는 것이다.
타입스크립트에서는 이렇게 하기 위해서 interface
를 하나 정의해야 한다.
interface InputProps {
label: string;
name: string;
kind?: "text" | "phone" | "price";
[key: string]: any;
}
[key: string]: any
는 어떠한 것이 props
를 통해 넘어오더라도 받아들이겠다는 뜻이다.
interface InputProps {
label: string;
name: string;
kind?: "text" | "phone" | "price";
type: string;
register: UseFormRegisterReturn;
required: boolean;
}
물론 위와 같이 [key:string]: any
와 같은 코드를 없애고 모든 props 에 대해 명확히 정의해준다면, 범용성은 작아지지만 더 의도가 명확한 코드가 된다. 이게 실수할 일은 더 적은 코드이다.
레퍼런스
'프론트엔드 > React' 카테고리의 다른 글
React Effect Hook 이란? (0) | 2022.10.19 |
---|---|
리액트 컴포넌트 밖 변수 선언의 의미 (Feat. 리액트에서 절대 하면 안되는 것 1가지) (0) | 2022.10.14 |
React 컴포넌트 어떻게 나누고 재사용할 것인가? (0) | 2022.07.24 |
React Hook Form 소개 (0) | 2022.07.02 |