페블_
반짝이는 시냅스
페블_
전체 방문자
오늘
어제
  • 전체글 보기 (96)
    • QA (0)
    • 프로젝트 회고 (4)
    • 프로젝트 과정 기록 (12)
    • UI 구현 연구일지 (8)
    • Front-end (31)
      • Javascript (7)
      • CSS (10)
      • React (5)
      • Typescript (3)
      • Nextjs (3)
      • 스타일링 라이브러리 (3)
    • Back-end (0)
      • Express (0)
      • DB (0)
    • CS (0)
      • 자료구조 & 알고리즘 (0)
    • CI&CD (1)
    • 툴 사용법 (4)
      • Git (1)
      • Library&패키지 (2)
      • 기타 개발관련 (1)
    • 알고리즘 이론 & 풀이 (36)
      • 백준(BOJ) (14)
      • 프로그래머스 (22)

블로그 메뉴

  • 홈
  • 태그
  • 방명록

공지사항

인기 글

태그

  • 캐러셀
  • 백준
  • 선형대수학
  • JS
  • react
  • 알고리즘
  • 생각
  • 개발블로그_시작
  • UI 컴포넌트
  • Python
  • eslint
  • 토이프로젝트
  • TypeScript
  • emotion
  • 파이썬
  • 시리즈_표지
  • chartjs
  • storybook

최근 댓글

최근 글

티스토리

hELLO · Designed By 정상우.
페블_

반짝이는 시냅스

[React] 회원가입 유효성 검사 구현하며 겪은 시행착오와 react-hook-form 사용
프로젝트 과정 기록

[React] 회원가입 유효성 검사 구현하며 겪은 시행착오와 react-hook-form 사용

2023. 7. 31. 01:48

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
    '프로젝트 과정 기록' 카테고리의 다른 글
    • [React] Chartjs로 막대그래프 그리기
    • [JS] Chartjs 막대그래프 그리기 (별점 분포 차트)
    • [React] 카카오 지도 API - 키워드로 장소 검색하고 목록으로 보여주기 구현 +) Typescript
    • [CSS] Navbar 아랫부분의 그림자가 가려지지 않게 하는 법 +) 가상클래스, react-router-dom Outlet, MUI
    페블_
    페블_

    티스토리툴바