React Hook Form 실무 도입기, 그리고 Best Practice 탐구

been's devlog·2024년 5월 6일
0

이번에 회사에서 react hook forms를 도입하게 되어 그 과정에서 마주쳤던 문제점들과 best practice, 최적화 방식에 대해 정리해보려 한다.

도입하게 된 이유

현재 재직중인 회사에서 진행하는 프로젝트들의 주된 공통점 중 하나는 form 입력과 입력상태에 따라 적절한 ui 변화를 보여주었어야 했는데, 이를 나이스하게 처리해줄 라이브러리를 찾다 다음과 같은 이유 때문에 react hook form 을 사용하게 되었다.

  1. 지나치게 복잡해질 수 있는 form 구조 개선
    회사에서 진행하는 프로젝트들의 특성상 form & submit UI가 빈번하게 있어 빠르면서도 간결하게 작업할수 있는 툴이 필요했다.

  2. 렌더링 최적화
    React 함수형 컴포넌트의 렌더링 특성상 다수의 input form은 루트 컴포넌트에서 상태를 받아 작동하므로 렌더링 이슈가 발생할 수 있다. react-hook-form의 경우, 후술할 렌더링 최적화 매커니즘 때문에 별도의 처리 없이도 최적화가 가능하다.

  3. validation 관련 로직 재사용 & 체계화
    가장 중요한 요소로써, form에서 매우 중요한 것이 validation check인데, 기존 타 프로젝트에서는 이것이 재활용되지 않고, 코드가 일관적이지 않아 이에 대한 해결책이 필요했다. hook form에서는 자체적으로 form validation 관련 기능을 제공하여 손쉽게 관련 로직을 적용할 수 있었다.

React Hook Form가 form을 갱신하는 법

react-hook-form에서 가장 특이한 점 중 하나는 form 상태를 갱신하는 방식인데, 구글링을 통해 알아본 결과 useState와 같은 일반적 갱신 방법을 사용하는 것이 아닌 ref를 사용해 form element의 value를 직접 갱신하는 방법을 사용한다고 한다. (이를 비제어 컴포넌트라 한다.)

더 확실하게 확인하기 위해 register 부분을 직접 살펴볼 필요가 있다고 판단, 코드를 살펴보기로 했다. (코드 흐름을 따라가본거라 기능관련 설명은 정확하지 않을수 있다.)

export function useForm<
  TFieldValues extends FieldValues = FieldValues,
  TContext = any,
  TTransformedValues extends FieldValues | undefined = undefined,
>(
  props: UseFormProps<TFieldValues, TContext> = {},
): UseFormReturn<TFieldValues, TContext, TTransformedValues> {
  
// 생략
  
  // formControl current 초기화 부분
  if (!_formControl.current) {
    _formControl.current = {
      ...createFormControl(props),
      formState,
    };
  }
  
// 생략
  
return _formControl.current;
}

위 코드는 useForm hook 부분인데, 여기서 useForm hook은 _formControl.current를 리턴하고 있으며, 이는 createFormControl을 사용해 초기화되고 있는 것을 알 수 있다. createFormControl를 간략하게 설명하자면 register 관련 여러 상태, 메소드 getter, setter를 리턴하는 함수이다

  // useForm에서 가져댜 쓰는 그 register
  const register: UseFormRegister<TFieldValues> = (name, options = {}) => {
  // 생략
    
  updateValidAndValue(name, true, options.value);

updateValidAndValue를 계속 파고들어가면 setFieldValue 함수를 만날 수 있다.

  // form의 value를 직접적으로 updat하는 함수
  const setFieldValue = (
    name: InternalFieldName,
    value: SetFieldValue<TFieldValues>,
    options: SetValueConfig = {},
  ) => {
    // target field 획득 후 fieldReference에 element ref 할당하는것으로 추정됨
    const field: Field = get(_fields, name);
    let fieldValue: unknown = value;
    if (field) {
      const fieldReference = field._f;
      // 생략
      
      // 다음과 같이 초기화 (실제로는 훨씬 복잡하며 input type별로 별도 초기화 수행)
      fieldReference.ref.value = fieldValue;
    }
  }

form의 업데이트를 담당하는 setFieldValue에서는 다음과 같이 target field의 HTMLInputElement 엘리먼트를 참조하는 ref에 값을 할당하는 것을 확인할 수 있다.

이러한 방식으로 업데이트를 수행하게 되면 리렌더링을 유발하지 않기 때문에 form 컴포넌트에서 발생할 수 있는 문제점인 렌더링 퍼포먼스 이슈에서 자유로울 수 있다. 하지만 watch, error message 등 일반적인 react 상태 갱신 API 또한 존재하기 때문에 이 점은 염두해두어야 하며, 후술하겠지만 이러한 방식이 가질 수 있는 문제점에 주의하면서 사용하면 좋겠다.(getValues vs useWatch 부분 참고)

작업 중 발생했던 문제점

react-hook-form을 사용하여 작업을 어느정도 진행한 결과 다음과 같은 애로사항이 발생하였다.

1. form element의 react-hook-form과의 너무 강한 결합도

필자가 프로젝트를 살펴보고 느꼈던 가장 주요한 문제점은 react-hook-form input element와 결합도가 강해지기 쉬우며, 컴포넌트 하나에 상태, onchange event, validation, alert, submit 등등 여러 기능이 한번에 들어가게 되어 스파게티 구조가 되기 쉽다는 점을 문제로 보았다.

// 다음과 같이 form 관련 코드가 한번에 들어가있어 코드 수정, 재사옹 등이 힘들어진다.
<div>
  <label htmlFor="amount">Amount:</label>
  <input
    {...register("amount", {
      required: "Amount is required",
      validate: (value) =>
        (parseAmount(value) >= 100 && parseAmount(value) <= 500) ||
        "Amount must be between 100 and 500",
    })}
    type="text"
    placeholder="Enter amount"
    onBlur={(e) => {
      e.target.value = formatAmount(e.target.value);
    }}
  />
  {errors.amount && <p>{errors.amount.message}</p>}
</div>

이러한 문제를 해결하기 위해 우선 컴포넌트를 총 3단계로 분리하기로 했다.

  • form 컴포넌트 MyForm -> form의 관리를 담당
  • Input UI + react-hook-form 연동 컴포넌트 AmountInput -> form sync, update, validation 등의 기능
  • Input UI컴포넌트: Input UI
// form 페이지 컴포넌트
const MyForm = () => {
  const form = useForm();
  const {
    register,
    handleSubmit,
    formState: { errors },
  } = form;

  const onSubmit = (data) => {
    // submit data
  };

  <FormProvider {...form}>
    <form onSubmit={handleSubmit(onSubmit)}>
      <AmountInput name="amount" />
    </form>
  </FormProvider>;
};
  
// Input + react-hook-form 연동 담당 컴포넌트
const AmountInput = ({ name }) => {
  const { control, register } = useFormContext();
	
  return (
    <Input
      {...register(name, {
        required: "Amount is required",
        validate: (value) =>
          (parseAmount(value) >= 100 && parseAmount(value) <= 500) ||
          "Amount must be between 100 and 500",
      })}
      type="text"
      placeholder="Enter amount"
      onBlur={(e) => {
        e.target.value = formatAmount(e.target.value);
      }}
    />
  );
};

// input ui 컴포넌트
const Input = ({ ...props }) => {
  return <input className="my-input" {...props} />;
};

그리고 react-hook-form에서 제공하는 FormProvider를 사용하여 prop drilling 현상을 방지하고 더 유연한 재사용이 가능하도록 하였다.

// 어느 컴포넌트에서든 다음과 같이 가져다 쓸 수 있다.
<AmountInput name="amount1" />
<AmountInput name="amount2" />
<AmountInput name="amount3" />

2. 사용자에게 보여지는 input값과 내부적으로 저장되는 input값이 서로 달라야 할 경우

다음과 같은 기능을 가진 form이 있다고 하자.
1. input으로 숫자를 입력받는다.
2. input에 보여지는 form에는 ,가 추가되어야 한다.
3. 서버에 제출하여야 하는 데이터에는 ,가 없어야 한다.
ex)입력시: 123,456원이 노출, submit 시 서버에 123456으로 전달

Form.js

  // submit 함수
  const onSubmit = (data) => {
    // submit 시에는 쉼표를 제거하고 값을 전달
    const myNumber = data.myNumber.replace(/,/g, '');
	//... api 호출해 전달
  };

Input.js

  const handleInputChange = (e) => {
    // , 로 변환
    setValue('myNumber', numberWithComma.toLocaleString());
  };

  return (
  	  <input
        {...register('myNumber', { required: 'Number is required' })}
        type="text"
        onChange={handleInputChange}
        placeholder="Enter a number"
      />
  );
}

회사에서 관리하는 기존 코드의 경우, input ui에서 사용자에게 보여줄 input으로 변환한 뒤, submit 시점에서 이를 다시 변환하는 방식을 사용하였는데, 이 경우 값 변환 관련 코드가 input, submit 두군데서 관리되기 때문에 SoC 측면에서 좋지 않다.

많은 고민 끝에 다음과 같이 작성하게 되었다. handleInputChange(onChange)에서 submit할 데이터의 포맷을, useWatch를 사용해 input value를 변환해주는 구조로 작성하였다.

  const curMyNumber = useWatch({ control, name: 'myNumber' });

  // submit form에 맞는 형식으로 변환
  const handleInputChange = (e) => {
    // , 제거
    const newValue = e.target.value.replace(/,/g, '');
    setValue('myNumber', newValue);
  };

  // 사용자에게 보여줄 format으로 변환
  const displayMyNumber = curMyNumber.toLocaleString();

  return (
    <input
      {...register('myNumber', { required: 'Number is required' })}
      type="text"
      onChange={handleInputChange}
      placeholder="Enter a number"
	  value={curMyNumber}
    />
  );

form 검증 재사용
필자가 가장 많이 고민했던 부분, 해당 기록은 다음에 다시 작성할 예정이다.

주의사항

watch vs useWatch

react hook form에서 특정 form 요소의 변화를 감지하는 방법을 watch, useWatch 이렇게 2개 제공한다. 하지만 큰 이유가 없는 이상 필자는 렌더링 관련 이슈로 useWatch 사용을 권장하는데, 공식문서에 따르면 다음과 같다.

watch 의 경우,

이 API는 당신의 application의 root 혹은 Form에서 리렌더링을 발생시킬 것이며, 퍼포먼스 이슈가 있는경우, callback 혹은 useWatch API 사용을 고려해야 한다.

반면 useWatch 의 경우,

useWatch: watch API와 비슷하게 동작하지만, custom hook level 에서만 독립적으로 리렌더링이 발생하며 잠재적으로 application에 더 좋은 퍼포먼스를 가져다 줄 수 있다.

다음과 같이 watch 사용시, 루트 레벨에서 리렌더링이 발생하여 react-hook-form의 도입 목적 중 하나인 퍼포먼스 개선을 퇴색시킬 수 있어 useWatch를 사용하는 것이 좋겠다.

getValues vs useWatch

둘 다 최신 form의 상태를 가져온다는 점은 동일하지만 생각보다 큰 차이점이 있다. 다음 예제를 살펴보자.

function MyForm() {
  const { register, handleSubmit, getValues } = useForm();

  const handleGetValue = () => {
    // 다음과 같이 getValues를 사용해 최신 lastName 획득 가능
    const curLastName = getValues('lastName');
    console.log(curLastName);
  };

  return (
    <form onSubmit={handleSubmit(onSubmit)}>
      <button type="button" onClick={handleGetValue}>
        Get Last Name
      </button>
      {/* Display the current last name */}
      <p>Current last name: {getValues('lastName')}</p>
    </form>
  );
}

여기서 handleGetValue를 호출할경우, 문제없이 최신 상태를 획득 가능하지만 아래와 같이 사용할 경우 원하는 결과값을 얻지 못할것이다.

{
  // curLastName은 최신 상태값을 획득할 수 없다.
  const curLastName = getValues('lastName');
  return (
    <p>Current last name: {curLastName}</p>
  )
}

이는 위에서 설명한 react-hook-form의 렌더링 원리와 관련이 있는데 useState 등의 일반 상태와는 다르게 ref는 react lifecycle과는 별도로 존재하며, 별도의 처리가 없는 한 두 상태는 완전히 독립적으로 갱신된다.

React Ref 공식문서 (Differences between refs and state 부분 참고)

그렇기 때문에 ref로 관리되는 form상태와 state를 동기화시켜 UI에 표시하려면 컴포넌트에 리렌더링을 발생시켜야 하는데, 결정적으로 getValues가 아닌 watch, useWatch만 리렌더링을 유발하기 때문에 이러한 문제가 발생하게 된다.

다시 말해, react 상태를 초기화, 갱신하려는 목적으로 getValues를 사용할 경우, 제대로 업데이트 되지 않을 수 있다는 것이다.

해결방법은 간단하다. 대신 useWatch를 사용하게 되면 항상 최신 상태를 획득할 수 있다.

  // 문제없이 최신 상태를 획득 가능하다.
  const curLastName = useWatch({ name: 'lastName' });
  return (
    <p>Current last name: {curLastName}</p>
  )

추가 팁

커스텀 form 전역 상태 추가하기

만약 form 상태 중 전역으로 사용할 상태를 추가하고 싶을 경우, 다음과 같이 커스텀 FormProvider를 만들어 사용할 수 있다.

const Form = () => {
  return (
    <MyFormProvider methods={methods}>
      <SetCounter/>
    </MyFormProvider>
  )
}

// custom provider 추가
const MyFormProvider = ({ methods, children }) => {
  // 전역에서 사용할 counter
  const [counter, setCounter] = useState(0);
  // props에 추가
  const props = {
    ...methods,
    counter,
    setCounter
  };

  return <FormProvider {...props}>{children}</FormProvider>;
};

const SetCounter = () => {
  // provider에서 등록한 counter 사용
  const { control, register, counter, setCounter } = useFormContext();
}

긴 글 봐주셔서 감사합니다. 잘못된 점이나 피드백 환영합니다. 감사합니다.

profile
더 나은 내일의 나를 목표로 하는 프론트엔드 개발자입니다.

0개의 댓글