트러블이슈/이약저약

에러 찾다가 react-hook-form 탐험하기

Ahyeon, Jung 2024. 5. 8. 14:32

form 내부의 데이터를 하나씩 상태관리하던 첫번째 프로젝트를 보다가, 하나의 객체로 관리하는 방식으로 변경했던 다음 프로젝트를 보면서, 더 좋은 방법이 있는데 내가 놓치고 있는게 아닐까 하는 생각이 들었다. 그때도 이런 생각을 했던건지 다음 프로젝트를 보니, react-hook-form을 통해 좀 더 쉬운 방법으로 상태관리를 하고 있었다. 하지만 코드를 보면 볼수록 이 라이브러리의 취지를 전혀 모르고 그냥 단순히 타입용으로만 사용하고 있는 것 같다는 생각을 했다.  아~ 타입추론 편하다~ 이게 이 라이브러리의 효용이라고 생각하고 그렇게 까지 좋은지 모르겠던데? 라고 생각했던 지난날,, 그래서 이번에 더 잘 사용해볼까 싶어서 react-hook-form을 다시 사용하고, 비제어라서 사이드이펙트가 없다는 장점이 있다거나 유효성 검사를 추가할 수 있었다. 그러나,, post 요청에는 잘 동작하는 handleSubmit(onSubmit)이 isEditing으로 patch 요청으로 바꿔주기만 하면 작동하지 않는 문제가 있었다. 더 무서운건,, 에러없는 에러라는 점이었다. 그냥 콜백 함수를 중간에 실종시키는 에러없는 에러,, 이게 진짜 어디서부터 손대야할지 감이 안온다. 그렇지만 어쩌겠어,, 사라진 내 콜백 함수 찾으러 떠나야지,,


제어 컴포넌트

제어 컴포넌트는 React에 의해 값이 제어되는, 사용자의 입력을 기반으로 자신의 state를 관리하고 업데이트하는 컴포넌트다. 아래와 같이 input의 입력값들을 useState를 이용해 상태로 관리하여 제어할 수 있다. 제어 컴포넌트는 사용자가 입력한 값과 저장되는 값을 실시간으로 동기화할 수 있다.

export default function App() {
  const [name, setName] = useState("");
  const [gender, setGender] = useState("");
    
  const onChange = (e) => {
    setName(e.target.value);
  };

  return (
    <div className="App">
      <label>이름</label>
      <input onChange={onChange} value={name} />
      <label>성별</label>
      <input onChange={onChange} value={gender} />
    </div>
  );
}

 

일반적으로는 하나의 상태에서 객체로 깔끔하게 정리할 수 있다.

export default function App() {
  const [body, setBody] = useState({ name: '', gender: ''});
  
  const onChange = (e) => {
    setBody({ ...body, [e.target.name]: e.target.value });
  };

  return (
    <div className="App">
      <label>이름</label>
      <input name="name" onChange={onChange} value={body.name} />
      <label>성별</label>
      <input name="gender" onChange={onChange} value={body.gender} />
    </div>
  );
}

 

비제어 컴포넌트

비제어 컴포넌트는 상태로 관리하지 않고 자바스크립트와 같이 onSubmit 이벤트가 발생할 때 useRef로 요소 내부의 값을 얻어온다. 따라서 실시간으로 동기화되지 않는다. 제어 컴포넌트의 경우 사용자가 입력할 때마다 상태가 변경되면서 리렌더링을 발생시키는데, 비제어 컴포넌트는 사용자가 액션을 하기 전까지는 리렌더링을 발생시키지 않을 수 있다. 초기값같은 경우 defaultValue, defaultChecked 등 사용하여 설정할 수 있다. <input type="file" />은 프로그래밍적으로 값을 설정할 수 없고 사용자만이 값을 설정할 수 있기 때문에 항상 비제어 컴포넌트이다.

export default function App() {
  const nameRef = useRef(null);
  const genderRef = useRef(null);
  
  const handleSumbit = (e) => {
    e.preventDefault();
    const name = nameRef.current.value;
    const gender = genderRef.current.value;
  };

  return (
    <form className="App" onSubmit={handleSumbit}>
      <label>이름</label>
      <input ref={nameRef} />
      <label>성별</label>
      <input ref={genderRef} />
      <button type="submit">제출</button>
    </form>
  );
}

 

비제어 컴포넌트와 제어 컴포넌트

feature uncontrolled controlled
one-time value retrieval O O
validating on submit O O
instant field validation X O
conditionally disabling submit button X O
enforcing input format X O
several inputs for one piece of data X O
dynamic inputs  X O

 

비제어 컴포넌트는 onSubmit 이벤트가 발생했을때 비로소 값을 가져오므로 입력값이 변경될 때마다 유효성 검사를 하거나, 제출 버튼을 비활성화하거나, 형식을 강제하거나, 하나의 데이터에 대한 여러 입력을 통해 수집하거나 동적 입력을 할수는 없다.


React Hook Form

react-hook-form은 기본적으로 ref를 이용하여 비제어컴포넌트 방식으로 구현되어 있다. 리액트의 폼 상태를 쉽게 비제어 컴포넌트로 관리할 수 있게 돕는 라이브러리다. 공식문서의 예제를 살펴보면 쉽게 이해할 수 있다.

 

watch를 통해서 폼 상태를 지켜보고 touched된 필드들을 기록하는 것을 확인할 수 있고 onSubmit이 되었을 때 유효성 검사를 실시한다. 이외에도 다양한 예시를 통해서 시각적으로 파악할 수 있다. 

import { useForm, SubmitHandler } from "react-hook-form"

type Inputs = {
  example: string
  exampleRequired: string
}

export default function App() {
  const {
    register,
    handleSubmit,
    watch,
    formState: { errors },
  } = useForm<Inputs>()
  const onSubmit: SubmitHandler<Inputs> = (data) => console.log(data)

  console.log(watch("example"));

  return (
    <form onSubmit={handleSubmit(onSubmit)}>
      <input defaultValue="test" {...register("example")} />
      <input {...register("exampleRequired", { required: true })} />
      {errors.exampleRequired && <span>This field is required</span>}
      <input type="submit" />
    </form>
  )
}

 

기본적으로 useForm 훅을 통해 register, handleSumbit, watch, formState를 반환받아 사용한다.register는 input의 입력 요소를 react-hook-form에 등록하고, react-hook-form은 해당 입력 요소의 상태를 추적하고 유효성 검사를 수행할 수 있다. handleSumibt은 폼 제출시 실행될 코드를 지정하는데, 제출 이벤트를 막고 등록된 폼 데이터를 수집하여 인자로 받은 콜백함수를 onSumbit에 전달한다. watch는 입력 필드의 값을 실시간으로 감시할 수 있게해주며, formState는 폼의 현재 상태를 나타낸다. 


 

기존에 onChange가 발생할 때마다 리렌더링된다는게 괜찮은건가 싶은 생각을 했었기 때문에, react-hook-form의 취지는 확실히 이해할 수 있었다. 그래서 회원가입이나 영양제 리뷰를 남기는 등 폼이 필요할 때는 react-hook-form을 사용했다. 그러나 등록된 리뷰에서 편집을 할 때 갑자기 onSubmit해도 상호작용이 일어나지 않는 문제가 발생했다. 게다가 우리 팀은 컴파운드 컴포넌트 패턴에 취해서 Form 안에서 useForm 를 생성해서 name을 전달하면 입력하는 방식이었다. 추상화 한다는 것,, 2주 지난 시점에서 안정화되지 않거나 api 잘못 연결해놓으면 3주차부터는 추상화 어떻게 했는지 기억도 안나서 결국 다시 코드 하나하나 읽어봐야한다는 것..

지금 에러 원인을 아는 상황에서 내 코드에 뭐가 잘못되었는지를 살펴봤어야지 왜 잘 구현되어있는 hanldeSubmit에 꽂혀서 어려운 길을 갔는지 모르겠다. 나를 의심하기보다 라이브러리 의심하는 태도 정말 참 멋지다!

 

누가 내 콜백함수를 가져갔는가

 

일단 의심되는 것을 파악하기 위해서 DevTools를 다시 실행시켰다. formState의 field에 touched와 dirty를 확인하고 알아봤지만, touched는 사용자가 상호작용했는지 여부고, Dirty는 변경 여부였다. 하루종일 react-hook-form 코드만 보다보니까 지치기도 하고 handleSumit 안에서는 어떤 일이 일어나는지, 애초에 useForm이 갖고 있는 값들은 무엇인가에 대해서 살펴보며 처음으로 라이브러리를 까보았다. 물론 많이 다치고 나왔다. 그래도 대충 어떤 컨셉을 가졌는지 알고 들어가니까 어거지로라도 해석하다보면서 끼워맞춰서 이해한 척은 할 수 있었다.

const [formState, updateFormState] = React.useState<FormState<TFieldValues>>({
    isDirty: false,
    isValidating: false,
    isLoading: isFunction(props.defaultValues),
    isSubmitted: false,
    isSubmitting: false,
    isSubmitSuccessful: false,
    isValid: false,
    submitCount: 0,
    dirtyFields: {},
    touchedFields: {},
    validatingFields: {},
    errors: props.errors || {},
    disabled: props.disabled || false,
    defaultValues: isFunction(props.defaultValues)
      ? undefined
      : props.defaultValues,
  });
  • isDirty: 현재 폼이 수정되었는지를 나타낸다
  • isValidating: 현재 유효성을 검사 중인지 여부를 나타낸다
  • isLoading: 기본값 함수가 존재하면 폼이 로딩 중인 것으로 간주된다
  • isSubmitted: 폼이 제출되었는지 여부를 나타낸다
  • isSubmitting: 폼이 제출 중인지 여부를 나타낸다
  • isSubmitSuccessful: 폼 제출이 성공적으로 완료되었는지 여부를 나타낸다
  • isValid: 폼이 유효한지를 나타낸다,

 

실제로 내 코드를 찍어보면 잘 제출되었음을 확인할 수 있었다.

찾았다 범인,, handleSubmit 내부엥서 내 뭔가 막혀서 실행이 안돼고 있었다. 

useForm의 구성

export type UseFormReturn<
  TFieldValues extends FieldValues = FieldValues,
  TContext = any,
  TTransformedValues extends FieldValues | undefined = undefined,
> = {
  watch: UseFormWatch<TFieldValues>;
  getValues: UseFormGetValues<TFieldValues>;
  getFieldState: UseFormGetFieldState<TFieldValues>;
  setError: UseFormSetError<TFieldValues>;
  clearErrors: UseFormClearErrors<TFieldValues>;
  setValue: UseFormSetValue<TFieldValues>;
  trigger: UseFormTrigger<TFieldValues>;
  formState: FormState<TFieldValues>;
  resetField: UseFormResetField<TFieldValues>;
  reset: UseFormReset<TFieldValues>;
  handleSubmit: UseFormHandleSubmit<TFieldValues, TTransformedValues>;
  unregister: UseFormUnregister<TFieldValues>;
  control: Control<TFieldValues, TContext>;
  register: UseFormRegister<TFieldValues>;
  setFocus: UseFormSetFocus<TFieldValues>;
};

 

처음에 바로 useForm을 살펴보니 진짜 읽히지도 않았다. 그래서 먼저 반환값에는 어떤 게 있는지부터 확인해야했다. 반환값들의 세부 타입들까지 살펴보지 못했지만, 필드 명을 통해서 대충 어떤 역할을 하는 친구들일지는 이해할 수 있었다. 이때 변수명을 왜 직관적으로 써야한다는건지 와닿았다..

export function useForm<
  TFieldValues extends FieldValues = FieldValues,
  TContext = any,
  TTransformedValues extends FieldValues | undefined = undefined,
>(
  props: UseFormProps<TFieldValues, TContext> = {},
): UseFormReturn<TFieldValues, TContext, TTransformedValues> {
  const _formControl = React.useRef<
    UseFormReturn<TFieldValues, TContext, TTransformedValues> | undefined
  >();
  const _values = React.useRef<typeof props.values>();
  const [formState, updateFormState] = React.useState<FormState<TFieldValues>>({
    isDirty: false,
    isValidating: false,
    isLoading: isFunction(props.defaultValues),
    isSubmitted: false,
    isSubmitting: false,
    isSubmitSuccessful: false,
    isValid: false,
    submitCount: 0,
    dirtyFields: {},
    touchedFields: {},
    validatingFields: {},
    errors: props.errors || {},
    disabled: props.disabled || false,
    defaultValues: isFunction(props.defaultValues)
      ? undefined
      : props.defaultValues,
  });

  if (!_formControl.current) {
    _formControl.current = {
      ...createFormControl(props),
      formState,
    };
  }

  const control = _formControl.current.control;
  control._options = props;

  useSubscribe({
    subject: control._subjects.state,
    next: (
      value: Partial<FormState<TFieldValues>> & { name?: InternalFieldName },
    ) => {
      if (
        shouldRenderFormState(
          value,
          control._proxyFormState,
          control._updateFormState,
          true,
        )
      ) {
        updateFormState({ ...control._formState });
      }
    },
  });

  React.useEffect(
    () => control._disableForm(props.disabled),
    [control, props.disabled],
  );

  React.useEffect(() => {
    if (control._proxyFormState.isDirty) {
      const isDirty = control._getDirty();
      if (isDirty !== formState.isDirty) {
        control._subjects.state.next({
          isDirty,
        });
      }
    }
  }, [control, formState.isDirty]);

  React.useEffect(() => {
    if (props.values && !deepEqual(props.values, _values.current)) {
      control._reset(props.values, control._options.resetOptions);
      _values.current = props.values;
      updateFormState((state) => ({ ...state }));
    } else {
      control._resetDefaultValues();
    }
  }, [props.values, control]);

  React.useEffect(() => {
    if (props.errors) {
      control._setErrors(props.errors);
    }
  }, [props.errors, control]);

  React.useEffect(() => {
    if (!control._state.mount) {
      control._updateValid();
      control._state.mount = true;
    }

    if (control._state.watch) {
      control._state.watch = false;
      control._subjects.state.next({ ...control._formState });
    }

    control._removeUnmounted();
  });

  React.useEffect(() => {
    props.shouldUnregister &&
      control._subjects.values.next({
        values: control._getWatch(),
      });
  }, [props.shouldUnregister, control]);

  _formControl.current.formState = getProxyFormState(formState, control);

  return _formControl.current;
}

 

애초에 들어갈때부터 이해할 수는 없다고 생각했었고, 그냥 이왕인거 관람해보자는 마음이었다. 역시나 이 방대한 라이브러리를 다 이해할 수는 없었고, 그냥 useForm 하나만 잡고 코드를 잘 읽어보자고 결심했다. 진짜 보는 내내 내 멋대로 해석해서 완전한 해석도 아니고 아예 틀렸을 수도 있지만, 읽고 나니까 생각보다 재미있었다.

export function useForm( props ): {
  const _formControl = React.useRef("반환값" | undefined);

  const _values = React.useRef<typeof props.values>();

  const [formState, updateFormState] = React.useState("폼 상태" | undefined);

  if (!_formControl.current) {
    _formControl.current = {
      // register, handleSubmit 등의 control 객체 반환
      ...createFormControl(props),
      formState,
    };
  }

  const control = _formControl.current.control;
  
  control._options = props;  // mode, defaultValue 등등

  useSubscribe({
    subject: control._subjects.state,
    next: (value: "폼 필드 값 중 하나" & { name?: "폼 필드 이름 하나" }) => {
      if (
        // 조건에 따라 폼 상태를 렌더링해야하는지 확인
        shouldRenderFormState(
          value,
          control._proxyFormState,
          control._updateFormState,
          true,
        )
      ) {
        updateFormState({ ...control._formState });
      }
    },
  });

  // props로 받은 disabled가 변경될 때 폼을 비활성화
  React.useEffect(
    () => control._disableForm(props.disabled),
    [control, props.disabled],
  );

  // isDirty 변경 감지
  React.useEffect(() => {
    if (control._proxyFormState.isDirty) {
      const isDirty = control._getDirty();
      if (isDirty !== formState.isDirty) {
        control._subjects.state.next({
          isDirty,
        });
      }
    }
  }, [control, formState.isDirty]);

  // values 변경사항있을 때 새로운 값과 현재 값을 비교하여 폼 값 업데이트
  React.useEffect(() => {
    if (props.values && !deepEqual(props.values, _values.current)) {
      control._reset(props.values, control._options.resetOptions);
      _values.current = props.values;
      updateFormState((state) => ({ ...state }));
    } else {
      control._resetDefaultValues();
    }
  }, [props.values, control]);

  // 에러에 변경사항 있을 때 폼 에러 업데이트
  React.useEffect(() => {
    if (props.errors) {
      control._setErrors(props.errors);
    }
  }, [props.errors, control]);

  // 컴포넌트의 마운트 및 언마운트 처리
  React.useEffect(() => {
    if (!control._state.mount) {
      control._updateValid();
      control._state.mount = true;
    }

    if (control._state.watch) {
      control._state.watch = false;
      control._subjects.state.next({ ...control._formState });
    }

    control._removeUnmounted();
  });

  // 감시중인 값 업데이트
  React.useEffect(() => {
    props.shouldUnregister &&
      control._subjects.values.next({
        values: control._getWatch(),
      });
  }, [props.shouldUnregister, control]);

  _formControl.current.formState = getProxyFormState(formState, control);

  return _formControl.current;
}

 

나는 공통 컴포넌트 하나 만드는데도 빼먹은 상태가 많아서 프로젝트 진행하는 매주 추가했는데, 라이브러리는 이렇게 많은 케이스를 대비해서 그렇게 안정적이구나 하는 생각을 할 수 있었다. update나 should나 직관적인 명도 계속해서 눈에 들어왔다. 그리고 읽는 내내 내 코드의 잘못이 100%라고 확신할 수 있었다.

 

다시 돌아와서 내 에러 찾기

일단 발표 삼일 전이었기 때문에 오픈소스 라이브러리를 잠깐 맛본 것에 만족하고, 다시 내 코드로 돌아가 내가 무엇을 놓쳤나 코드를 다시 읽었다. 그러다가 유효성 검사에 medicineId를 만났다. 음,, 편집때는 필요없는데,,? yup.medicineID: . post 요청과 patch 요청이 다른데 한 컴포넌트에서 그냥 props 여부로 케이스를 나누다보니, 유효성검사를 다르게 했어야하는데 하지 않았고, 이로 인해서 handleSumibt에 들어갔다가 유효성 검사로 막혀서 erros 객체에 숨어있었던 것이었다.. 말 좀 해주지..가 아니었다.

 

다시 에러 객체를 찍어보니 대놓고 medicineId가 나왔다. 어제는 없다며?

어제는 에러를 찍으니까 무한 렌더링이 발생하면서 에러 객체도 비어있다고 떴는데, 무한 렌더링도 안하고 medicineId없다고 잘 안내해주고 있었다.. 나는 어제도 에러 객체를 찍어보고, 오늘도 찍어봤고, 코드는 달라진게 없는데,,

 

그리고 ReviewEditModal과 ReviewPostModal을 따로 만들고 스키마와 유효성 검사도 따로 만들면서 해결할 수 있었다. 이를 통해서 단일 책임의 원칙과 컴파운트 컴포넌트의 효용에 대해서 눈물흘리면서 느낄 수 있었다. 내가 크게 봐도 두 가지의 책임을 컴포넌트에 부여해놓고 조건문으로 처리하다보니 상황 분기가 나스스로가 제대로 되지 않았고 조건이 군데 군데 붙으면서 흐름을 파악할 수 없었던게 큰 원인이었다. 그래서 바로 분리를 시켰는데, 이때 Form을 컴파운드 컴포넌트 패턴으로 넘겨주면서 스타일을 활용할 필요가 없어서 그냥 거의 상태와 함수만 만들어서 똑같이 넘겨주는게 끝이었다. 고민없이 일단 하다보면 흐름을 잃고, 다른 사람이 이해하기 어렵다는 것을 알게 되었다. 그 근자감으로 인한 뜻밖의 라이브러리 탐험을 할 수 있었다.


일단 해결하고 넘어갔다가 발표 끝나고 원인을 찾아보려고 콘솔을 다 찍어봤다. 그리고 원인은 기본기 부족한 나였다. const에 객체를 할당하게 되면, 동일한 객체에 대한 참조를 공유한다. 그래서 yesterdayErrors는 methods.formState와 같은 객체를 가리킨다. 그리고 error는 methods.formState의 errors를 errors 속성 값이 errors 변수에 저장되는 개념이다. 즉, methods.formState 값이 변경되게 되면, yesterdayErrors도 변경되기 때문에 무한 렌더링이 발생하게 된 것이고, errors의 경우 객체의 errors 값을 저장한 것이기 때문에 무한렌더링이 발생하지 않는다. 물론 객체를 할당한다고 해서 무한렌더링이 발생하는 것은 아니지만 formState는 내부에서 체크하는 과정에서 계속 변경되기 때문에 이러한 무한 렌더링이 발생했다..