thumbnail
기술아티클
React-Hook-Form Deep Dive - 2. useForm(개요)
React Hook Form의 가장 중요한 hook인 useForm의 개괄적인 내용을 살펴봅니다.
December 25, 2024

이번 아티클을 시작으로 총 4편에 걸쳐서 RHF에서 가장 많이 사용하는 useForm에 대해 분석해볼것입니다. 먼저 이번 아티클에서는 useForm의 개략적인 구조에 대해 살펴보고 다음 아티클부터 등록, 변경, 제출 세가지 행위에 대해 살펴볼것입니다.

useForm

먼저 우리가 useForm에서 꺼내어 사용하는 register, formState, handleSubmit은 어디서 오는것인지, 핵심 로직은 어디에 있는지 살펴보겠습니다. 아래 코드는 useForm에서 중요하지 않은 부분을 생략한 코드임을 참고해주세요.

export function useForm<
  TFieldValues extends FieldValues = FieldValues,
  TContext = any,
  TTransformedValues extends FieldValues | undefined = undefined,
>(
  props: UseFormProps<TFieldValues, TContext> = {},
): UseFormReturn<TFieldValues, TContext, TTransformedValues> {
  // 1. _formControl, formState 변수 및 _formControl 변수 할당
  const _formControl = React.useRef<
    UseFormReturn<TFieldValues, TContext, TTransformedValues> | undefined
  >();
  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;

  // 2. control의 state 구독
  useSubscribe({
    subject: control._subjects.state,
    next: (
      value: Partial<FormState<TFieldValues>> & { name?: InternalFieldName },
    ) => {
       updateFormState({ ...control._formState });
    },
  });


  // 3. _formControl.current 반환
  return _formControl.current;
}

첫번째 항목을 보면 useForm에서 _formControlformState라는 두개의 값을 관리하고 있으며, _formControlcreateFormControl의 실행결과와 formState를 넣어주고 있음을 확인할 수 있습니다. 이때 if(!_formControl.current) 코드로 인해 처음 마운트시에만 할당된다는점도 짚고 넘어가면 좋겠습니다. 왜냐하면 이로인해 createFormControl는 마운트시 단 한번만 실행되기 때문입니다.

두번째 항목을 보면 formStatecontrol._subjects.state subject의 구독을 받도록 하고 있음을 확인할 수 있습니다. 즉 formStateuseForm에서 단독으로 변경되는것이 아니라 formControlstate가 변경되어야 같이 변경될 수 있습니다.

세번째 항목을 보면 _formControl.current를 반환하고 있습니다. 이를 통해 주요 메서드들이 createFormControl의 실행결과에 들어있음을 알 수 있습니다.

createformControl

createFormControl를 실행하면 여러 메서드들이 존재하는 객체가 반환됩니다. 이 메서드를 이용해 formControl 내부의 상태를 조작하거나 꺼내어 사용하기도 합니다. 따라서 우리가 추후 register등의 메서드 동작을 이해하려면 객체 내부에 선언된 변수를 잘 이해하고 있어야 합니다.

// 1. 옵션값
let _options = {
  ...defaultOptions,
  ...props,
};

// 2. 현재 폼의 상태.
let _formState: FormState<TFieldValues> = {
  submitCount: 0,
  isDirty: false,
  isLoading: isFunction(_options.defaultValues),
  isValidating: false,
  isSubmitted: false,
  isSubmitting: false,
  isSubmitSuccessful: false,
  isValid: false,
  touchedFields: {},
  dirtyFields: {},
  validatingFields: {},
  errors: _options.errors || {},
  disabled: _options.disabled || false,
};

// 3. 폼에 등록된 요소
let _fields: FieldRefs = {};

// 4. 폼의 기본값
let _defaultValues =
  isObject(_options.defaultValues) || isObject(_options.values)
    ? cloneObject(_options.defaultValues || _options.values) || {}
    : {};

// 5. form의 value
let _formValues = _options.shouldUnregister ? {} : cloneObject(_defaultValues);

// 6. form의 내부적인 상태
let _state = {
  action: false,
  mount: false,
  watch: false,
};

// 7. form 요소의 상태
let _names: Names = {
  mount: new Set(),
  unMount: new Set(),
  array: new Set(), // array field가 저장됩니다.
  watch: new Set(), // watch 상태의 value
};

// 8. formState의 프록시 상태
const _proxyFormState: ReadFormState = {
  isDirty: false,
  dirtyFields: false,
  validatingFields: false,
  touchedFields: false,
  isValidating: false,
  isValid: false,
  errors: false,
};

// 9. 여러 객체를 구독할수 있는 subject
const _subjects: Subjects<TFieldValues> = {
  values: createSubject(),
  array: createSubject(),
  state: createSubject(),
};

첫번째 항목은 _options입니다. 기본옵션과 사용자가 useForm 실행시 넣은 옵션을 합쳐서 옵션 객체를 생성합니다.

두번째 항목은 _formState입니다. 앞서 useForm에서 보았던 formState의 원천이며 isValid, errors등 폼의 다양한 상태값을 확인할 수 있습니다.

세번째 항목은 _fields입니다. 현재 폼에 등록된 요소의 정보가 저장되는곳입니다. 예를 들어 <input {...register("test")}>와 같은 코드가 있다면 name, ref등이 객체형태로 저장됩니다.

네번째 항목은 defaultValue입니다. 옵션으로 넘긴 defaultValuesvalues를 이용해 기본값 객체를 생성하는데, defaultValues의 우선순위가 더 높으며 결정된 기본값을 복사하여 저장합니다.

다섯번째 항목은 _formValues입니다. _fields에 등록된 요소의 value를 저장해두는곳입니다. 다만 주의할것은 value라고 해서 항상 ref.value에서 값을 가져오는것이 아니라는 사실입니다. 체크 박스 타입의 input은 ref.checked에서 가져오기도합니다.

여섯번째 항목은 _state입니다. 내부적으로 폼의 렌더링을 위해 사용하는 상태로 외부에 제공하는 값은 아닙니다. action은 필드배열과, mount는 폼의 마운트와(렌더링과 무관합니다), watch는 폼의 관찰여부와 관련있습니다(watch 상태에 대해서는 링크를 참고해보세요).

일곱번째 항목은 _names입니다. _state와 비슷하지만, 개별 필드의 상태를 저장해 두는 변수라고 생각하시면 되겠습니다. 마운트, 언마운트, 필드배열폼요소, 관찰상태인 필드를 저장합니다.

여덟번째 항목은 _proxyFormState입니다. 이는 formState의 프록시 객체인데 관련하여 바로 아래에서 더 자세하게 살펴볼것입니다.

아홉번째 항목은 _subjects입니다. 내부적으로 관리하는 값인 values, array, state 세가지 subject를 가지고 있습니다.

debounce 이해하기

debounce란, 일정시간동안 해당함수가 재호출되지않아야 해당 함수가 실행되도록 하는것을 의미합니다. 보통 폼을 제출하거나, 자동완성을 수행할때 너무많은 api호출이 발생하지 않도록 처리할때 사용하게됩니다. RHF내부에서도 이를 위한 간단한 로직이 존재하는데, 한번 살펴보겠습니다.

let timer = 0;

const debounce =
  <T extends Function>(callback: T) =>
  (wait: number) => {
    clearTimeout(timer);
    timer = setTimeout(callback, wait);
  };

일반적으로 lodash와 같은 라이브러리의 디바운스를 사용하겠지만 RHF에서는 직접 만들어 사용하고있습니다.

먼저 debounce는 실행할 콜백함수를 넘기면 함수가 실행되고, 이를 지연할 시간만큼 넣어서 실행하면 timer에 setTimeout을 건 타이머를 저장합니다. 이후 콜백을 넘겨 실행한 함수를 재실행하지 않는다면 콜백이 실행되고 그렇지 않으면 타이머가 다시 생성됩니다.

RHF에서 debounce를 사용하는 경우는 delayError를 위해서입니다. delayError는 에러 발생시 결과 통보를 지연시키는것인데, 에러가 발생하면 이를 디바운스 처리해 delayErrorCallback에 담아두고 일정시간동안 에러가 발생하지 않을때 이를 제출하게됩니다.

formState에서 사용하는 필드가 변경될때만 리렌더링하는 useForm

앞서 formControl의 변수를 분석할때 넘어갔었던 _proxyFormState이 어떤 역할을 하는것인지 살펴보겠습니다.

일반적으로 useForm을 사용할때는 formState의 값을 사용하지만 모든 프로퍼티를 사용하는 경우는 거의 없습니다. 보통 errors, isValid 정도는 많이 사용하지만, isDirty, touchedFields등의 formState는 특수한 상황에서만 사용하게됩니다.

React에서 해당 값의 정확성을 보장하기 위해서는 해당 값이 변경될때 항상 리렌더링을 해주어야하는데, 문제는 formState의 값들이 변경되는 시점이 달라서 formState의 모든 프로퍼티가 변경될때마다 리렌더링을 발생시키면 거의 제어컴포넌트 수준으로 리렌더링이 발생할수 있다는 점입니다. 예를들어 errors가 변경되더라도 isValid는 변경되지 않을수 있는데, isValid만 사용중이더라도 리렌더링이 발생합니다.

이러한 문제를 해결하기 위해서 RHF에서는 formState에 프록시를 적용하여 현재 사용중인 프로퍼티가 변경되었을때만 리렌더링이 발생하도록 하고 있습니다. 예를 들어 아래와 같은 코드에서는 isValid가 변경될때만 리렌더링이 일어나고, errors가 변경되었을때는 리렌더링이 발생하지 않습니다.

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

  return (
    <form onSubmit={handleSubmit(onSubmit)}>
      <input {...register("exampleRequired", { required: true })} />
	  <input {...register("exampleMin", { min: 5 })} />
      {isValid ? <span>입력값이 모두 유효합니다</span> : <span>유효하지 않은 입력값이 있습니다</span>}
      <input type="submit" />
    </form>
  )
}

proxy가 적용되는 원리

사용중인 formState가 변경되었을때만 리렌더링을 발생시키는 원리를 살펴보겠습니다. 이를 위해 앞서 살펴보았던 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 [formState, updateFormState] = React.useState<FormState<TFieldValues>>({..});

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

  // 새롭게 추가된 라인
  _formControl.current.formState = getProxyFormState(formState, control);

  return _formControl.current;
}

여기서 새롭게 추가된 코드는 반환문 바로위에 있는 _formControl.current.formState = getProxyFormState(formState, control); 입니다. 코드는 간단한데, formState에 getProxyFormState(formState, control);함수의 실행결과를 할당합니다. 이것이 어떤 결과를 발생시키는지는 getProxyFormState 함수를 살펴보면서 이해해봅시다.

export default <TFieldValues extends FieldValues, TContext = any>(
  formState: FormState<TFieldValues>,
  control: Control<TFieldValues, TContext>,
) => {
  const result = {
    defaultValues: control._defaultValues,
  } as typeof formState;

  for (const key in formState) {
    Object.defineProperty(result, key, {
      get: () => {
        const _key = key as keyof FormState<TFieldValues> & keyof ReadFormState;

        if (control._proxyFormState[_key] !== VALIDATION_MODE.all) {
          control._proxyFormState[_key] = VALIDATION_MODE.all;
        }

        return formState[_key];
      },
    });
  }

  return result;
};

formState객체의 모든 프로퍼티에 get 프록시를 적용하는 함수입니다. 프록시로 어떤 역할을 하는지 살펴보기에 앞서서 프록시 자체에 대한 이해가 필수적이므로 프록시에 대해서 간략하게 이야기해보겠습니다.

프록시는 특정 객체를 감싸 프로퍼티 읽기, 쓰기와 같은 객체에 가해지는 작업을 중간에서 가로채는 객체를 말합니다. 예를들어 객체에 특정값을 할당할때 값을 검사하여 값을 할당하거나, 값을 조회할때 로거를 달수도있습니다. 현재 자바스크립트에서는 이를 위한 Proxy 내장 객체가 있지만 최근에 추가된 문법이기 때문에 기본적으로 get과 set에 대한 프록시는 defineProperty 함수를 사용하게됩니다. RHF도 하위호환성을 위해 defineProperty를 사용하였으며 Proxy와의 차이는 전체 객체에 적용하느냐, 개별 프로퍼티에 적용하느냐 정도의 차이로 이해해주시면 되겠습니다. 더 세부적인 차이가 궁금하시다면 링크를 참고해보세요.

이제 get 프로퍼티에 할당된 콜백함수 내부를 살펴보겠습니다. 해당 콜백함수가 호출될때마다 control._proxyFormState의 key 프로퍼티 값이 VALIDATION_MODE.all로 설정되지 않았다면 이것으로 설정해주고 원래 역할인 조회 결과를 반환합니다. 이렇게 하면 우리가 formState의 객체에서 어떤 프로퍼티를 꺼내어 사용하는지 control에서 알 수 있게 됩니다. 왜냐하면 사용하는 값은 control._proxyFormState 객체에서 동일한 이름의 프로퍼티 값이 VALIDATION_MODE.all로 변경되어있을것이기 때문입니다.

이 때문에 control 내부에서는 상태 값을 업데이트 하기전에_proxyFormState에서 해당 상태가 조회되었는지 여부를 확인한후, 업데이트 혹은 사전 작업을 수행합니다. 왜냐하면 해당 값을 사용하지 않으면 업데이트할 필요가 없기 때문입니다. 실제로 이러한 로직이 적용되어있는 함수를 살펴보겠습니다.

const _updateValid = async (shouldUpdateValid?: boolean) => {
  if (_proxyFormState.isValid || shouldUpdateValid) {
    const isValid = _options.resolver
      ? isEmptyObject((await _executeSchema()).errors)
      : await executeBuiltInValidation(_fields, true);

    if (isValid !== _formState.isValid) {
      _subjects.state.next({
        isValid,
      });
    }
  }
};

함수 본문의 첫번째 라인에 있는 조건문을 살펴보면 _proxyFormState.isValid에 값이 있는지 여부를 먼저 검사합니다. 다만 _proxyFormState.isValid가 falsy한 값이더라도 shouldUpdateValidtrue이면 아래 로직을 실행하는데 이는 shouldUpdateValid가 예외조건이기 때문입니다. 따라서 일반적인 상황에서는 _proxyFormState.isValid의 값이 있을때만 아래 로직이 실행되고 _subjects.state.next가 실행되어 isValid의 변경점이 useForm에 전파됩니다.

다만 여기서 _subjects.state.next를 통해서 상태를 업데이트 한다고 해서 formState에 바로 반영되는것은 아닙니다. 왜냐하면 useForm에서 state의 subject를 구독할때 제공하는 next콜백 함수₩에서 무조건 setState를 실행하지는 않기 때문입니다.

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

코드를 보면 shouldRenderFormState의 값이 true일때만 최종적으로 값에 반영됨을 알 수 있습니다. shouldRenderFormState 함수를 한번 살펴봅시다.

export default <T extends FieldValues, K extends ReadFormState>(
formStateData: Partial<FormState<T>> & { name?: InternalFieldName },
_proxyFormState: K,
updateFormState: Control<T>['_updateFormState'],
isRoot?: boolean,
) => {
updateFormState(formStateData);
const { name, ...formState } = formStateData;

return (
  // 1. 첫번째 조건
  isEmptyObject(formState) ||
  // 2. 두번째 조건
  Object.keys(formState).length >= Object.keys(_proxyFormState).length ||
  // 3. 세번째 조건
  Object.keys(formState).find(
    (key) =>
      _proxyFormState[key as keyof ReadFormState] ===
      (!isRoot || VALIDATION_MODE.all),
  )
);
};

반환에 사용되는 세가지 조건을 살펴보기에 앞서서 formStateData에서 name을 분리함을 볼 수 있습니다. 이는 formState에 name이 없기 때문입니다. 굳이 필요없는 name을 포함시킨 이유는 여기서 사용되지는 않지만, 이 업데이트를 발생시킨 필드가 누구인지 알 필요가 있는 경우가 있기 때문입니다.

첫번째 조건은 formState가 빈객체인지 여부입니다. 만약 빈객체 라면 formStateDataname만 포함되어있거나 빈 객체인 경우입니다. 이때는 강제로 formState를 업데이트 하라는 플래그와 동일한 의미를 가집니다.

두번째 조건은 _proxyFormState의 요소보다 더 많은 요소를 업데이트 한 경우입니다. 이경우도 마찬가지로 _proxyFormState에 담기지 않은 값을 업데이트 하려는 것이므로 업데이트 하게됩니다.

세번째 조건은 formState중에서 하나라도 _proxyFormState를 변경한 이력이 있는지 검사합니다. 하나라도 사용중이라면 리렌더링이 발생해야하므로 모두 업데이트 시키게 됩니다.

따라서 위 조건을 만족하게 되면 드디어 업데이트가 이루어져 사용중인 컴포넌트에 formState가 반영됩니다.

마치며

useForm 메서드들을 분석하기 위한 준비는 모두 끝났습니다. 다음장에서는 등록과정을 분석해 보겠습니다.

참고자료

Object.defineProperty() Proxy와 Reflect