실무에서 react hook form 도입하여 개선된 점들

진돌·2024년 7월 27일

React

목록 보기
3/3


안녕하세요.
회사에서 react hook form을 어떤식으로 사용했는지 작성해보려고 합니다.
react hook form의 구현이 어떻게 되어있냐 보다 어떤 문제를 해결해주었냐를 중점으로 소개 드리도록 하겠습니다.

react-hook-form 이 무엇인가요?

https://react-hook-form.com/
먼저 react-hook-form은 form 데이터 상태들을 효율적으로 관리해주고, 코드 양을 압도적으로 줄여주는 라이브러리입니다.
(토스에서도 주요 라이브러리로 사용되고 있는 라이브러리입니다.)

어떤 경우에 사용하나요?

사용자가 어떠한 event를 통해 json을 핸들링 하는 시나리오에서는 전부 다 사용할 수 있습니다. object로 관리하는 state는 useState나 useReducer를 사용하지 않아도 될 수 있어요.
ex) 모든 정보입력 및 수정, 필터 검색기능, 회원가입, 로그인 등

react hook form 간단한 예시

아래 아주 간단한 예시를 통해 설명 드리겠습니다.

정말 흔하게 볼 수 있는 회원가입 form 이네요.
email password의 state가 필요하고 error message state도 필요하겠군요.

react hook form을 사용하지 않고 작성한 코드를 먼저 보여드리겠습니다.

useState를 사용한 form 관리

  • container
const Container = React.memo(function Container() {
  const [state, setState] = React.useState({ email: '', password: '' });
  const [errorMessage, setErrorMessage] = React.useState<{ email: null | string; password: null | string }>({
    email: null,
    password: null,
  });

  return (
    <VStack gap={1} width={'100%'}>
      <Typography variant="Title_Looko">로그인 정보</Typography>

      <EmailForm state={state} setState={setState} errorMessage={errorMessage} setErrorMessage={setErrorMessage} />
      <PasswordForm state={state} setState={setState} errorMessage={errorMessage} setErrorMessage={setErrorMessage} />

      <Button onClick={() => {
        	//... state 핸들링
        }} color="primary" variant="contained">
        다음
      </Button>
    </VStack>
  );
});
  • email input
const EmailForm = ({
  state,
  setState,
  errorMessage,
  setErrorMessage,
}: {
  state: {
    email: string;
    password: string;
  };
  setState: React.Dispatch<
    React.SetStateAction<{
      email: string;
      password: string;
    }>
  >;
  errorMessage: {
    email: null | string;
    password: null | string;
  };
  setErrorMessage: React.Dispatch<
    React.SetStateAction<{
      email: null | string;
      password: null | string;
    }>
  >;
}) => {
  return (
    <FormRow
      label="이메일"
      inputComponent={
        <Input
          error={!!errorMessage.email}
          value={state.email}
          onChange={e => {
            const value = e.target.value;
            if (!value) {
              setErrorMessage(prev => ({ ...prev, email: '이메일을 입력해주세요' }));
              return;
            }

            if (!isValidEmail(value)) {
              //유효한 email인지 validation 하는 함수
              setErrorMessage(prev => ({ ...prev, email: '유효한 이메일 주소를 입력해주세요' }));
              return;
            }

            setErrorMessage(prev => ({ ...prev, email: null }));
            setState(prev => ({ ...prev, email: e.target.value }));
          }}
        />
      }
      errorMessage={errorMessage.email}
    />
  );
};
  • password input
const PasswordForm = ({
  state,
  setState,
  errorMessage,
  setErrorMessage,
}: {
  state: {
    email: string;
    password: string;
  };
  setState: React.Dispatch<
    React.SetStateAction<{
      email: string;
      password: string;
    }>
  >;
  errorMessage: {
    email: null | string;
    password: null | string;
  };
  setErrorMessage: React.Dispatch<
    React.SetStateAction<{
      email: null | string;
      password: null | string;
    }>
  >;
}) => {
  return (
    <FormRow
      label="비밀번호"
      inputComponent={
        <Input
          error={!!errorMessage.password}
          value={state.password}
          onChange={e => {
            const value = e.target.value;
            if (!value) {
              setErrorMessage(prev => ({ ...prev, password: '비밀번호를 입력해주세요' }));
              return;
            }

            if (!isValidPassword(value)) {
              //유효한 password인지 validation 하는 함수
              setErrorMessage(prev => ({ ...prev, password: '8자~16자의 영문, 숫자, 특수문자를 사용해주세요' }));
              return;
            }

            setErrorMessage(prev => ({ ...prev, password: null }));
            setState(prev => ({ ...prev, password: e.target.value }));
          }}
        />
      }
      errorMessage={errorMessage.password}
    />
  );
};

짠! 아주 길고 긴 코드가 완성되었습니다.
많은 props들을 넘겨주는 것부터 error 처리까지 작성하는데 정말 힘들었습니다..
이렇게 힘들게 작성했는데 성능에도 문제가 있습니다.

useState로 object를 관리하고 있다보니 password를 변경만 해도 Container 컴포넌트와 EmailForm 컴포넌트도 함께 계속 렌더링이 발생하게 됩니다.

이런 현상이 이런 form이 list로 있는 데이터라면... 정말 끔찍한 성능을 보여줄 것입니다.

react-hook-form을 사용한 form 관리

  • Container
const Container = React.memo(function Container() {
  const methods = useForm<{ email: string; password: string }>();

  return (
    <VStack gap={1} width={'100%'}>
      <form
        onSubmit={methods.handleSubmit(v => {
          // 데이터 핸들링
        })}
      >
        <FormProvider {...methods}>
          <EmailForm />
          <PasswordForm />
        </FormProvider>
      </form>
    </VStack>
  );
});
  • EmailForm
const EmailForm = () => {
  const {
    register,
    formState: { errors },
  } = useFormContext<{ email: string }>();
  return (
    <FormRow
      label="이메일"
      inputComponent={
        <Input
          {...register('email', {
            required: '이메일을 입력해주세요',
            validate: v => isValidEmail(v) || '유효한 이메일 주소를 입력해주세요',
          })}
        />
      }
      errorMessage={errors.email?.message}
    />
  );
};
  • PasswordForm
const PasswordForm = () => {
  const {
    register,
    formState: { errors },
  } = useFormContext<{ password: string }>();
  return (
    <FormRow
      label="이메일"
      inputComponent={
        <Input
          {...register('password', {
            required: '비밀번호를 입력해주세요',
            validate: v => isValidPassword(v) || '8자~16자의 영문, 숫자, 특수문자를 사용해주세요',
          })}
        />
      }
      errorMessage={errors.password?.message}
    />
  );
};

위에 useState 작성하는데 시간에 비해 10분의 1도 안든 것 같네요.
정말 간단하죠? 코드 양이 얼마나 줄었는지 보이시나요?
수많은 form 데이터를 state로 관리했을 때의 코드양은 정말 끔찍했습니다.

게다가 react hook form은 컴포넌트를 비제어로 다루어서 렌더링도 최적화 되었습니다.

코드양, 렌더링, props drilling
react-hook-form을 사용하는 것만으로도 코드의 난이도가 훨씬 낮아지고 최적화 되었습니다.

실무에서 겪는 문제 시나리오

회사에서 겪은 시나리오를 토대로 개선한 내용들을 설명드리도록 하겠습니다.

흔히 볼 수 있는 필터 UX 입니다.
약간의 설명을 드리면 취소 상품들을 관리해주는 데이터 리스트를 가져오는 필터예요!

도입 전에는 이런식으로 작성이 되어있었어요.

  • 도입전 (디자인과 일치하지 않습니다. 비즈니스 로직만 이해해주세요!)
type CancleOrderRequestType = { // 서버 query 데이터와 일치하는 타입 필터 UI와 무관
	toDate: number; //기간 to 한달전
  	fromDate: number; //기간 from 오늘날짜
  	cancleStatus: '전체' | '미승인' | '승인완료'// 취소 승인 여부
  	platform: '전체' | '네이버' | '카페24' | '에이클로젯'; //플랫폼
	page: number;
	pageSize: number;
	userID: string;
	
	...
}
const Container = React.memo(function Container() {
  const [state, setState] = React.useState<Partial<CancleOrderRequestType>>({//initRequest});
  
  const [errorMessage, setErrorMessage] = React.useState({
    toDate: null,
    fromDate: null,
    cancleStatus: null,
    platform: null,
  });
 
  const disabled = Object.values(errorMessage).some(v => !v)

  return (
    <VStack gap={1} width={'100%'}>
      
      <Filter.기간 state={state} setState={setState} errorMessage={errorMessage} setErrorMessage={setErrorMessage} />
      <Filter.취소승인여부 state={state} setState={setState} errorMessage={errorMessage} setErrorMessage={setErrorMessage} />
      <Filter.플랫폼 state={state} setState={setState} errorMessage={errorMessage} setErrorMessage={setErrorMessage} />

      <Button disabled={disabled} onClick={() => {
        	//... state 핸들링
        }} color="primary" variant="contained">
        검색
      </Button>
    </VStack>
  );
});

각각의 필터 내부로직은 위에서 설명드렸던 input들과 비슷한 흐름이라고 생각하시면 됩니다. (error 상태 관리, state validation)

자! 위에서 얘기한 것을 봤을 때 위 코드가 어떤 문제들이 있는지는 아실겁니다.

그 문제에 더해서 실제로 추가적인 기획 시나리오를 넣어보겠습니다.

(배포 직전)
🥸 (UX디자이너): 플랫폼 필터가 필요 없어졌습니다! 지워주세요!
(필터 하나만 슥 지워지는 거니까 간단하겠지?)
😾 (개발자): 넵!
(플랫폼 필터 컴포넌트를 지우고, error 관리하는 state에서 platform을 빼줘야하네!)

UX디자이너가 생각하는 공수가 약간 차이가 나네요.
다른 시나리오도 추가해보겠습니다.


위에 이미지는 다른 화면에서 사용되고 있는 필터입니다.
상품코드, 상품명, 카테고리 모두 CancleOrderRequestType에는 이미 있다고 가정해봅시다.

(배포 직전)
🥸 (UX디자이너): 상품코드, 상품명, 카테고리 필터를 넣어주세요!
(이미 있는 UI니까 가져다가 넣으면 끝이겠지?)
😾 (개발자): 넵!
(음.. error state들을 다 추가해줘야하네!! props들 다 연결해줘야하네!! )


디자이너나 기획자는 단순히 컴포넌트를 지우거나 추가하는 일을 요청했는데, 저희는 비즈니스 로직까지 건드리면서 일이 복잡해지는군요.

(배포 직전)
🥸 (UX디자이너): 안에 값들이 변경될 때 error를 확인하지 말고 검색 버튼을 누를 때 error 상태를 확인해주세요!
(어려운가? 간단하려나? 간단하겠지?)
😾 (개발자): 지금 당장은 불가능합니다.
( error validation 함수를 전부 검색 버튼 누를 때 확인해서 error state를 넣어줘야하네. 답이 없다.)

각 필터 내부에서 하고 있던 validation 함수들을 가져온다고 생각해봅시다.
위에서 겪은 필터 컴포넌트를 빼거나 추가할 때는 더더욱 정말 끔찍하겠군요.
validation 하기 위한 hook을 커스텀으로 만들어서 관리하는 등 로직이 점점 복잡해지고 말겠네요.

실무에서 디자이너나 기획자분들의 생각과는 다르게 개발 복잡도가 높은 것은 정말 흔히 있는 일입니다. 어쩔 수 없는 일입니다.

하지만 지금 상황에서도 어쩔 수 없는 것일까요?

react-hook-form을 사용하는 세계에서는 절대 그렇지 않습니다.

  • 도입후
const Container = React.memo(function Container() {
  const methods = useForm<CancleOrderRequestType>({mode: 'all'});
  
  const disabled = Object.keys(methods.formState.errors).length > 0;

  return (
    <VStack gap={1} width={'100%'}>
      <form
        onSubmit={methods.handleSubmit(v => {
          // 데이터 핸들링
        })}
      >
        <FormProvider {...methods}>
         <Filter.기간 />
      	 <Filter.취소승인여부 />
	     <Filter.플랫폼 />

         <Button type={'submit'} disabled={disabled} onClick={() => {
                //... state 핸들링
            }}>
            검색
         </Button>
        </FormProvider>
      </form>
    </VStack>
  );
});

코드는 정말 깔끔해졌죠?

아까 위에서 겪은 시나리오를 넣어보겠습니다.

🥸 (UX디자이너): 플랫폼 필터는 지우고 상품코드, 상품명, 카테고리 필터를 넣어주세요!


const Container = React.memo(function Container() {
  const methods = useForm<CancleOrderRequestType>({mode: 'all'});
  
  const disabled = Object.keys(methods.formState.errors).length > 0;

  return (
    <VStack gap={1} width={'100%'}>
      <form
        onSubmit={methods.handleSubmit(v => {
          // 데이터 핸들링
        })}
      >
        <FormProvider {...methods}>
         <Filter.기간 />
      	 <Filter.취소승인여부 />
         <Filter.상품코드 />
         <Filter.상품명 />
         <Filter.카테고리 />
          
         <Button type={'submit'} disabled={disabled} onClick={() => {
                //... state 핸들링
            }}>
            검색
         </Button>
        </FormProvider>
      </form>
    </VStack>
  );
});

😾 (개발자): 넵!
(설명할게 없습니다. 지우고 넣어줬습니다.)

각각의 필터 컴포넌트들은 rules를 이용해서 내부에서 react-hook-form formState를 알아서 관리해주고 있습니다.
부모 컴포넌트에서는 자식이 뭐가 있든 신경쓸 일이 아닙니다.

이번엔 validation 위치 수정을 했던 경우를 봐볼까요?

(배포 직전)
🥸 (UX디자이너): 안에 값들이 변경될 때 error를 확인하지 말고 검색 버튼을 누를 때 error 상태를 확인해주세요!
(어려운가? 간단하려나? 간단하겠지?)

  const methods = useForm<CancleOrderRequestType>({mode: 'onSubmit'}); //onSubmit 외에도 blur touch 등 다양하게 제어 가능

😾 (개발자): 넵!
(설명할게 없습니다. mode를 변경해주었습니다.)

어떤 문제를 해결했나요?

1. 코드의 파편화 줄임
store, context, atom 등을 사용하지 않아 코드파편화를 최소화 하였습니다. (위에 예시 코드로는 props들을 사용했지만 store랑 context를 사용한 경우)

2. 관심사 분리
다른 컴포넌트들이 사라지거나 생기는 것에 따라서 다른 곳에서 코드 수정이 없어졌습니다.

3. 불필요한 렌더링 제거
비제어 컴포넌트로 구성되어 렌더링을 최적화 해줍니다.

4. 유지보수 증대
압도적인 코드양 감소로 코드리뷰 등 코드를 다시 보는 일이 생길 때 로직을 파악하기가 매우 쉬워졌습니다.

마무리

react-hook-form은 react, react-native에서 form 관련 모든 시나리오를 대처해줄 수 있는 아주 멋진 라이브러리입니다.
회사에서는 앱과 웹 모두 react-hook-form을 통해 관리하기 좋은 코드를 작성해내고 있습니다.

여기서는 기본적인 기능으로만 설명했지만, 아주 많은 기능들이 있어서 이벤트를 통해 Json을 만들어내는 모든 시나리오에서는 사용될 수 있습니다.

여기까지 회사에서 react-hook-form을 직접 도입하면서 해결한 문제들을 소개 해드렸습니다.

감사합니다!

0개의 댓글