TL;DR
onChange마다 유효성 검사를 하면 리렌더링이 너무 잦고 에러 메시지 관리도 힘들다.
react-hook-form은 보통 submit시 유효성 검사를 한다고 알려져 있지만, onChange때마다 검사하도록 mode를 설정할 수도 있다. react-hook-form으로 리렌더링 횟수를 줄이고 유효성 검사를 간편하게 해보자.
1. 시행착오 과정
recoil로 직접 값 검증 구현 vs react-hook-form 이용하기
나는 react-hook-form은 비제어 컴포넌트 방식이고, 제출시에 값 검증을 하는 라이브러리라고 알고 있었다.
난 onChange마다 유효성 검사를 하고 싶었었기 때문에 react-hook-form 사용 대신 input을 별도의 컴포넌트로 분리하고, 컴포넌트 내부에 recoil로 값을 가지고 있게 해 리렌더링을 최소화할 계획을 세우게 된다.
... 근데 이렇게 만드려니까 뭔가가 잘못됐다는 것을 느꼈다.
이 무시무시하게 많은 props가 보이는가? FormInput은 아이디, 패스워드 등 다양한 input을 공통화한 컴포넌트이기 때문에 이렇게 사용할지 안할지 모르는 props와 interface 설정을 모두 해줘야 한다.
import { TextField } from '@mui/material';
import React, { useCallback, useState } from 'react';
import { RecoilState, useRecoilState } from 'recoil';
import useFormCheck from '../../../hooks/useFormCheck';
interface FormInputProps {
id: string;
name: string;
atomState: RecoilState<string>;
label: string;
maxLength?: number;
autoComplete?: string;
placeholder?: string;
helperText?: React.ReactNode;
onFocusBlur?: React.FocusEventHandler;
isFocused?: boolean;
}
게다가 자체 컴포넌트 내부에 useState로 error를 관리하려고 했는데, 생각해보니 상위 페이지에서 login 하기 전 error가 하나도 없는지 체크하는 과정에서 이 error State가 필요했다.
그렇다면 error State를 상위로 끌어올려야 한다.
idError, passwordError 이렇게 따로 관리하려면 너무 상태가 많아지므로 객체 형태의 state로 에러를 관리하게 될 텐데,
알다시피 객체형 state는 한 요소가 변경되면 전체가 리렌더링 된다. 리렌더링 막으려고 컴포넌트 분리한 의미가 없게 된다. 아니면 또 error도 각각 Recoil idErrorState 등을 만들어 주든가 해야 한다.
function FormInput({
// ... FormInput props들
}: FormInputProps)
function FormInput() {
const [value, setValue] = useRecoilState(atomState);
const [error, setError] = useState(false);
const { getValidatorByName } = useFormCheck();
const handleChange = useCallback(
(event: React.ChangeEvent<HTMLInputElement>) => {
const validator = getValidatorByName(name);
const errorExists = !validator(value);
setValue(event.target.value);
setError(errorExists);
},
[getValidatorByName, name, setValue, value]
);
return (
<TextField
id={id}
name={name}
label={label}
inputProps={{ maxLength: { maxLength } }}
required
autoComplete={autoComplete}
fullWidth
value={value}
onChange={handleChange}
placeholder={placeholder}
onFocus={onFocusBlur}
onBlur={onFocusBlur}
helperText={(isFocused || error) && helperText}
// focus 되어있거나 에러가 존재한다면 helperText 보여줌. focus가 false여도 error가 존재한다면 helperText 보여줌.
error={error}
/>
);
}
export default FormInput;
또한 id값 검증 로직, password 검증 로직을 일일이 props에 직접 붙이는 건 비효율적이기 때문에,
값 검증 로직을 따로 갖고 있다가 'id'처럼 이름을 전달하면 validate 여부를 리턴해주는 useFormCheck라는 hook도 만들어야 했다.
function FormInput() {
const [value, setValue] = useRecoilState(atomState);
const [error, setError] = useState(false);
const { getValidatorByName } = useFormCheck();
// ...
}
// useFormCheck.ts
type Validators = {
[key: string]: (value: string) => boolean;
};
const useFormCheck = () => {
const validators: Validators = {
id: (value: string) => {
// 아이디 유효성 검사 로직
return value.length >= 6;
},
password: (value: string) => {
// 비밀번호 유효성 검사 로직
return value.length >= 8;
},
name: (value: string) => {
return value.length >= 2;
},
mobile_no: (value: string) => {
return /^\d+$/.test(value);
},
email: (value: string) => {
return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value);
},
nickname: (value: string) => {
return value.length >= 2;
},
};
const getValidatorByName = (name: string) => {
return validators[name] || (() => true); // 해당하는 이름의 검사 함수가 없으면 항상 true 반환
};
return {
getValidatorByName,
};
};
export default useFormCheck;
음, 점점 추상화를 위해 너무 해야 할 일이 많아지고 있다....
2. react-hook-form 적용
npm install react-hook-form
이렇게 설치하면 사용할 준비는 끝이다.
위에서 저렇게 추상화 난리를 피운 이유는 필드 개수가 10개 정도로 많아서였는데, 기획이 구체화되다보니 4개의 필드밖에 남지 않게 되어, 그냥 검증 로직을 페이지에 바로 박아버리는게 더 효율적이라는 결론이 나왔다.
그래서 react-hook-form을 각 input에 직접 적용해 주었다.
마침 MUI를 사용하고 있어 TextField의 helperText와 error 기능을 사용해 쉽게 구현할 수 있었다.
react-hook-form 사용 예시
- Typescript를 사용한다면 검증할 form의 {필드명: 값} 쌍을 타입으로 명시해줘야 에러가 안난다.
- 보통은 제출 시에 값 검증이 이루어지지만, useForm에 mode로 'onChange'처럼 언제 유효성 검증을 할 지 설정할 수도 있다. 이러면 recoil이나 컴포넌트 내부 state로 값 관리하는 것보다는 렌더링이 자주 일어나지만 그래도 react-hook-form을 사용하지 않고 객체 형식의 state로 필드값을 관리할 때보나는 리렌더링이 덜 일어난다.
interface SignUpFormValues {
id: string;
password: string;
nickname: string;
email: string;
}
type Agreements = {
[key: string]: boolean;
};
export default function RegisterPage() {
const {
control,
handleSubmit,
getValues,
trigger,
formState: { errors, isValid },
} = useForm<SignUpFormValues>({
mode: 'onChange',
});
// id, nickname 검사 완료 및 사용가능 여부
const [isIdAvailable, setIsIdAvailable] = useState(false);
const [idCheckCompleted, setIdCheckCompleted] = useState(false);
const [isNicknameAvailable, setIsNicknameAvailable] = useState(false);
const [nicknameCheckCompleted, setNicknameCheckCompleted] = useState(false);
const onSubmit = (data: SignUpFormValues) => {
// 제출할 때 모든 요소들이 available하고 validate한지 검사 후 요청한다.
};
// id에 blur가 일어날 때마다 중복검사를 수행하도록 했다.
const handleIdBlur = async () => {
const value = getValues('id'); // 아이디 필드의 입력값 가져오기
if (value) {
try {
// Axios를 사용하여 중복검사 API 호출
const response = await axios.post('/api/checkDuplicateId', { id: value });
const isValid = response.data.isAvailable; // API에서 중복검사 결과를 받아옴
setIsIdAvailable(isValid); // 유효성 검사 결과에 따라 상태 변경
setIdCheckCompleted(true);
} catch (error) {
alert(`Error during id duplication check: ${error}`);
}
}
};
// id에 Blur가 일어날 때마다 중복검사를 수행
const handleNicknameBlur = async () => {
const value = getValues('nickname');
if (value) {
try {
// Axios를 사용하여 닉네임 중복검사 API 호출
const response = await axios.post('/api/checkDuplicateNickname', { nickname: value });
const isValid = response.data.isAvailable; // API에서 중복검사 결과를 받아옴
setIsNicknameAvailable(isValid);
setNicknameCheckCompleted(true);
} catch (error) {
alert(`Error during nickname duplication check: ${error}`);
}
}
};
- form 역할을 하는 컴포넌트에는 onSubmit에 useForm으로부터 가져온 handleSubmit을 전달한다.
handleSubmit(onSubmit)의 onSubmit은 제출할 때 수행할 동작을 담은 함수이다. - 유효성 검사를 할 컴포넌트는 Controller로 감싸고 props로 정규식 패턴, 에러 메시지를 적어준다.
<Box
component="form"
noValidate
onSubmit={handleSubmit(onSubmit)}
sx={{
display: 'flex',
flexDirection: 'column',
gap: 2,
pt: 2,
pb: 5,
}}
>
// registerPage.tsx에서 id 입력받는 input 부분
{/* id input */}
<Controller
name="id"
control={control}
defaultValue=""
rules={{
required: 'ID를 입력해주세요.',
pattern: {
value: /^[a-zA-Z0-9_-]{5,20}$/,
message:
'ID는 5~20자의 영문 대소문자, 숫자, 특수문자(-, _)만 사용 가능합니다.',
},
}}
render={({ field }) => (
<TextField
{...field}
label="아이디"
required
fullWidth
variant="outlined"
inputProps={{ maxLength: 20 }}
onBlur={handleIdBlur}
onChange={(e) => {
field.onChange(e); // 필드의 값을 변경
trigger('id'); // 입력 값이 변경될 때마다 id 필드의 값을 검사
setIsIdAvailable(false);
setIdCheckCompleted(false);
}}
error={!!errors.id || (!isIdAvailable && idCheckCompleted)} // id 제한사항에 통과 못했거나 id 중복 검사 결과 사용불가일 때도 error
helperText={
errors?.id
? errors.id.message // ID 유효성 검사 에러 메시지 표시
: idCheckCompleted && isIdAvailable
? '사용 가능한 아이디입니다.'
: idCheckCompleted && !isIdAvailable // 사용 가능한 경우 메시지 표시
? '이미 사용중인 아이디입니다.' // 이미 사용중인 경우 메시지 표시
: ''
}
/>
)}
/>
// ...
// 나머지 input들도 비슷하게 유효성 검사를 한다.
유효성 검사 mode를 onChange로 설정해줬기 때문에 onChange 함수는 따로 설정해 줄 필요는 없지만,
아이디 input에 change가 발생한다면 아이디 중복체크 완료 여부도 초기화해야 하기 때문에 onChange 함수를 작성해야 했다. 그러면 mode가 onChange여서 따로 이벤트 설정 안해도 value가 바뀌던 것을, 아래처럼 값 업데이트를 하도록 trigger해야 한다.
onChange={(e) => {
field.onChange(e); // 필드의 값을 변경
trigger('id'); // 입력 값이 변경될 때마다 id 필드의 값을 검사
setIsIdAvailable(false);
setIdCheckCompleted(false);
}}
이메일 input은 그런 로직이 없으므로 그냥 onChange 설정을 안 하고 라이브러리가 change하도록 냅뒀다.
{/* 이메일 input */}
<Controller
name="email"
control={control}
defaultValue=""
rules={{
required: '이메일을 입력해주세요.',
pattern: {
value: /^[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,4}$/i,
message:
'유효한 이메일 주소를 입력해주세요. 예) example@email.com',
},
}}
render={({ field }) => (
<TextField
{...field}
label="이메일"
required
fullWidth
variant="outlined"
placeholder="example@email.com"
error={!!errors.email}
helperText={errors.email && errors.email.message}
/>
)}
/>
닉네임이랑 패스워드 유효성 검사 로직도 첨부
{/* 닉네임 input */}
<Controller
name="nickname"
control={control}
defaultValue=""
rules={{
required: '닉네임을 입력해주세요. (최대 20자)',
maxLength: {
value: 20,
message: '닉네임은 20자 이하여야 합니다.',
},
}}
render={({ field }) => (
<TextField
{...field}
label="닉네임"
required
fullWidth
variant="outlined"
inputProps={{ maxLength: 20 }}
onBlur={handleNicknameBlur}
onChange={(e) => {
field.onChange(e);
setIsNicknameAvailable(false);
setNicknameCheckCompleted(false);
}}
error={
!!errors.nickname ||
(!isNicknameAvailable && nicknameCheckCompleted)
}
helperText={
errors?.nickname
? errors.nickname.message
: nicknameCheckCompleted && isNicknameAvailable
? '사용 가능한 닉네임입니다.'
: nicknameCheckCompleted && !isNicknameAvailable
? '이미 사용중인 닉네임입니다.'
: ''
}
/>
)}
/>
{/* password input */}
<Controller
name="password"
control={control}
defaultValue=""
rules={{
required: '비밀번호를 입력해주세요.',
minLength: {
value: 8,
message:
'8~20자의 영문 대/소문자, 숫자, 특수문자를 사용해 주세요.',
},
pattern: {
value: /^[a-zA-Z\d!"#$%&'()\\*+,\-./:;<=>?@₩[\]^_`{|}~]{8,20}$/,
message:
'8~20자의 영문 대/소문자, 숫자, 특수문자를 사용해 주세요.',
},
}}
render={({ field }) => (
<TextField
{...field}
type={showPassword ? 'text' : 'password'}
label="비밀번호"
required
fullWidth
variant="outlined"
inputProps={{ maxLength: 20 }}
InputProps={{
endAdornment: (
<InputAdornment position="end">
<IconButton
aria-label="toggle password visibility"
onClick={handleClickShowPassword}
edge="end"
>
{showPassword ? <VisibilityOff /> : <Visibility />}
</IconButton>
</InputAdornment>
),
}}
error={Boolean(errors?.password)}
helperText={errors?.password?.message || ''}
/>
)}
/>
(체크박스 로직은 다음 글에 작성 예정)
'프로젝트 과정 기록' 카테고리의 다른 글
[React] Chartjs로 막대그래프 그리기 (0) | 2023.08.04 |
---|---|
[JS] Chartjs 막대그래프 그리기 (별점 분포 차트) (0) | 2023.08.03 |
[React] 카카오 지도 API - 키워드로 장소 검색하고 목록으로 보여주기 구현 +) Typescript (0) | 2023.07.17 |
[CSS] Navbar 아랫부분의 그림자가 가려지지 않게 하는 법 +) 가상클래스, react-router-dom Outlet, MUI (0) | 2023.07.13 |
[MUI] material-ui <Link> 컴포넌트에 React-router-dom의 Link 적용하는 법 (0) | 2023.07.12 |