thumbnail
기술아티클
React-Hook-Form Deep Dive - 4. useForm(변경)
React Hook Form의 가장 중요한 hook인 useForm의 onChange메서드를 중심으로 변경과정을 살펴봅니다.
February 2, 2025

이번 장에서는 값이 변경될때 어떤일이 발생하는지 살펴보겠습니다.

앞서 등록을 살펴볼때 register메서드에서 반환하는 객체의 onChange프로퍼티에 onChanage메서드를 담아 반환하는것을 확인하였기에 <input {...regitser("name")}/> 와 같이 코드를 작성하면 필드의 값을 변경하였을때 change 이벤트가 발생하고 onChange 함수가 실행될것입니다.

따라서 이번 장에서는 onChange함수가 실행되는 과정을 따라가보면서 값을 변경하였을때 formControl내부에서는 어떤일이 발생하는지 알아보겠습니다.

최상위 변수 및 함수

onChange메서드를 살펴보면, 가장 상위에 변수와 함수들이 존재합니다. 주요 로직을 살펴보기 이전에 해당 변수들을 먼저 살펴보겠습니다.

// 1. _state.mount 상태 변경
_state.mount = true;

// 2. 필요 변수 확인
const target = event.target;
let name = target.name as string;
const field: Field = get(_fields, name);

// 3. isFieldValueUpdated 업데이트
let isFieldValueUpdated = true;
const _updateIsFieldValueUpdated = (fieldValue: any): void => {
  isFieldValueUpdated =
    Number.isNaN(fieldValue) ||
    deepEqual(fieldValue, get(_formValues, name, fieldValue));
};

// 4. field에 담겨있는 value를 추출합니다.
const getCurrentFieldValue = () =>
  target.type ? getFieldValue(field._f) : getEventValue(event);

첫번째 항목에서는 _state.mount의 값을 true로 변경해주고 있습니다. 따라서 _state.mounttrue로 변경되는 시점은 실제 컴포넌트가 마운트 되는 시점이 아니라 사용자가 값을 입력하여 변경이 발생하는 시점임을 알 수 있습니다. 앞서 등록을 살펴볼때 해당값이 true로 변경되는 시점을 추후 알아본다고 하였었는데, 그시점이 바로 change 이벤트가 발생한 지금입니다.

두번째 항목은 event객체에서 대상 요소를 꺼내 여기에 존재하는 이름을 기반으로 필드를 찾는 간단한 로직입니다. 앞서 많이 살펴본 로직이므로 이정도로 정리하고 넘어가겠습니다.

세번째 항목은 isFieldValueUpdated변수와 이를 변경하는 함수인 _updateIsFieldValueUpdated입니다. 함수는 isFieldValueUpdated를 변경하는데, 이는 필드값이 변경되었는지 여부를 판단하는 값입니다. 조건으로는 fieldValueNaN이거나, 기존의 _formValue의 값과 같으면 isFieldValueUpdatedtrue로 변경합니다. 왜 이러한 조건이 fieldValue가 업데이트 되었음을 의미하는지는 추후에 알 수 있을것입니다.

네번째는 getCurrentFieldValue 함수입니다. 이 함수는 target.type이 있는 경우 getFieldValue(field._f), 그렇지 않은 경우 getEventValue(event)를 실행합니다. target.type에 따라 분기되는 이유는, getFieldValue함수는 target.type에 따라 다른 방법을 적용해 값을 추출하고, getEventValue는 이벤트 객체에서 값을 꺼내기 때문입니다. 간단하게 두 함수를 살펴보겠습니다.

export default function getFieldValue(_f: Field["_f"]) {
  const ref = _f.ref;

  // 1. disabled 상태이면 빈값 반환
  if (_f.refs ? _f.refs.every((ref) => ref.disabled) : ref.disabled) {
    return;
  }

  // 2. type="file" input인 경우 files 반환
  if (isFileInput(ref)) {
    return ref.files;
  }

  // 3. type="radio" input인 경우 getRadioValue 함수 호출후 value 반환
  if (isRadioInput(ref)) {
    return getRadioValue(_f.refs).value;
  }

  // 4. type="select-multiple" input인 경우 selectedOptions에서 value 꺼내어 반환
  if (isMultipleSelect(ref)) {
    return [...ref.selectedOptions].map(({ value }) => value);
  }

  // 5. type="checkbox" input인 경우 selectedOptions에서 value 꺼내어 반환
  if (isCheckBox(ref)) {
    return getCheckboxValue(_f.refs).value;
  }
  
  // 6. type="text" input인 경우
  return getFieldValueAs(isUndefined(ref.value) ? _f.ref.value : ref.value, _f);
}

function getFieldValueAs<T extends NativeFieldValue>(
  value: T,
  { valueAsNumber, valueAsDate, setValueAs }: Field["_f"]
) {
  return isUndefined(value)
    ? value
    : valueAsNumber
    ? value === ""
      ? NaN
      : value
      ? +value
      : value
    : valueAsDate && isString(value)
    ? new Date(value)
    : setValueAs
    ? setValueAs(value)
    : value;
}

첫번째 항목을 살펴보면, disabled의 경우 빈값을 반환하도록 되어있습니다. 즉 disabled상태는 ref에 값이 있더라도 내부적으로는 값을 저장하지 않습니다.

두번째 부터 네번째 항목의 경우 각각 파일, 라디오, 멀티셀렉트, 체크 박스에 대해 값을 추출하고 있습니다. 이러한 분기가 존재하는 이유는 이 항목들에 대해서는 값을 추출하는 방법이 다르기 때문입니다.

다섯번째 항목의 경우 일반적인 텍스트 필드가 이에 해당할것입니다. 이경우 getFieldValueAs함수를 호출한 결과를 반환하는데, 이 함수를 살펴보면, valueAsNumber, valueAsDate, setValueAs의 옵션을 적용합니다. 이 옵션들은 값을 숫자나 날짜 혹은 커스텀 함수를 적용한 결과로 저장하도록 하는것입니다.

export default (event: unknown) =>
  isObject(event) && (event as Event).target
    ? isCheckBoxInput((event as Event).target)
      ? // checkbox는 checked가 값
        (event as Event).target.checked
      : // 그외는 value가 값
        (event as Event).target.value
    : event;

getEventValue 함수는 간단합니다. 체크박스의 경우에만 target에서 checked를 값으로 선택하고 그이외에는 모두 value를 값으로 선택합니다.

조건문 내부 변수

이제 if문 내부로 들어가 변수들을 살펴보겠습니다. 들어가기에 앞서서 이 조건문이 존재하는 이유에 대해 간략하게 살펴보고 넘어가겠습니다. 일반적인 상황에서는 필드가 없을수 없지만, register메서드를 외부에서 실행한뒤 register메서드 실행시 입력한 name과 다른 요소에 register 메서드의 결과를 담는 경우 필드가 없을수 있습니다.

const {onChange} = register("test")

const App = <input name="noop" onChange={onChange}/>

따라서 일반적인 상황에서 이 조건문이 else문으로 이동할 일은 없습니다.

// 1. 사용변수
let error;
let isValid;
const fieldValue = getCurrentFieldValue();
// 함수이름이 onChange이지만 onBlur에도 사용되므로 구분 필요
const isBlurEvent =
  event.type === EVENTS.BLUR || event.type === EVENTS.FOCUS_OUT;

// 2. validation 스킵 조건
const shouldSkipValidation =
  (!hasValidation(field._f) &&
    !_options.resolver &&
    !get(_formState.errors, name) &&
    !field._f.deps) ||
  skipValidation(
    isBlurEvent,
    get(_formState.touchedFields, name),
    _formState.isSubmitted,
    validationModeAfterSubmit,
    validationModeBeforeSubmit
  );

// 3. 현재 필드가 watch 상태인지 확인
const watched = isWatched(name, _names, isBlurEvent);

// 4. _formValues에 값 설정.
set(_formValues, name, fieldValue);

// 5. register 등록시 옵션으로 넘긴 onBlur, onChange 이벤트 핸들러를 현재 발생한 이벤트에 맞게 실행해줍니다.
if (isBlurEvent) {
  field._f.onBlur && field._f.onBlur(event);
  delayErrorCallback && delayErrorCallback(0);
} else if (field._f.onChange) {
  field._f.onChange(event);
}

// 6. updateTouchAndDirty
const fieldState = updateTouchAndDirty(name, fieldValue, isBlurEvent, false);

// 7. shouldRender
const shouldRender = !isEmptyObject(fieldState) || watched;

// 8. blur 이벤트는 포커스를 잃을때 동작하므로 값이 변경되지 않지만, change 이벤트인 경우 실질적으로 값이 변경되므로 values를 업데이트 해줍니다.
!isBlurEvent &&
  _subjects.values.next({
    name,
    type: event.type,
    values: { ..._formValues },
  });

// 9. validation을 패스한다면, 아래 라인을 실행하지 않아도 되므로 조기 반환합니다. 이때 렌더링이 꼭 필요하다면 state도 업데이트 합니다.
if (shouldSkipValidation) {
  if (_proxyFormState.isValid) {
    if (props.mode === "onBlur") {
      if (isBlurEvent) {
        _updateValid();
      }
    } else {
      _updateValid();
    }
  }

  return (
    shouldRender &&
    _subjects.state.next({ name, ...(watched ? {} : fieldState) })
  );
}

// 10. blur 이벤트가 아니고(change 이벤트이고), 현재 필드가 watched 상태이면 state를 업데이트 합니다.
!isBlurEvent && watched && _subjects.state.next({ ..._formState });

첫번째 항목은 사용변수입니다. 내부적으로 사용할 변수들을 선언합니다. error는 { "test":"값을 20자 미만으로 입력해주세요"} 와 같은 에러 객체, isValid는 현재 폼의 값이 유효한지의 여부, fieldValue에는 앞서 살펴본 getCurrentFieldValue 함수를 사용하여 요소의 값을 담습니다. isBlurEvent는 블러 이벤트 여부입니다. 메서드 이름이 onChange이지만, onBlur에도 사용되기 때문에 추후 분기를 위해 사용합니다.

두번째 항목은 유효성검사를 스킵하는 조건입니다. 크게 ||를 기준으로 두가지로 나눌수 있습니다. 첫번째 조건을 살펴보면 optionmin,max같은 native 로직이 포함되있는지 확인하는 hasValidationfalse이면서 resolver, errors, deps모두 없다면 굳이 유효성 검사를 할필요가 없습니다. 유효성 검사에 필요한 어떠한 정보도 제공하지 않았기 때문입니다. 두번째는 skipValidation함수를 실행합니다. 이 함수의 실행결과가 true일때만

세번째 항목은 현재 필드가 watch상태인지 확인합니다. 해당 함수를 살펴보면 좋지만, 간단하게 정리하면, change이벤트이면서, 모든 필드가 watch상태이거나, 혹은 현재 필드가 watch상태일때거나 상위 이름이 watch상태이면 이를 watch로 인정합니다.

네번째 항목은 _formValuesfieldValue를 설정합니다. 따라서 이 시점이후에는 _formValeusfieldValue가 설정되어있으므로 폼이 ref에 입력된 값을 알고있게됩니다. onChange메서드가 수행하는 간단하지만 가장 중요한 기능이 되겠습니다.

다섯번째 항목은 이벤트에 따라서 register메서드 실행시 옵션으로 넘긴 onBlur 혹은 onChange 이벤트 핸들러를 현재 발생한 이벤트에 맞게 실행해주는 코드입니다. 다만 이때 blur이벤트가 발생하면 delayErrorCallback함수를 실행하는 코드가 있습니다. 이 함수는 에러를 지연시킬경우 이를 debounce처리해둔 함수입니다. delayErrorCallback에 함수를 설정하는 부분은 아래에서 확인할수 있습니다. 여기서blur이벤트에만 이러한 로직이 존재하는 이유는 change이벤트와 달리 blur이벤트만 발생하였을때는 값이 변경되지 않기때문에 에러가 있었다면 그 에러가 계속 유지되므로 에러가 있다면 그대로 발생시키는 것입니다.

여섯번째 항목은 updateTouchAndDirty함수를 실행해 toucheddirty를 업데이트한뒤, 업데이트된 state를 반환하는 함수입니다. 특별히 중요한 동작은 없으므로 함수내부를 살펴보지는 않겠습니다.

일곱번째 항목은 shouldRender변수입니다. 변경될 fieldState가 있거나 없더라도 watched가 있으면 강제 업데이트를 수행합니다.

여덟번째 항목은 blur이벤트가아닌경우 즉, change이벤트에만 values를 업데이트 해주는 로직입니다. change이벤트에만 이를 업데이트 해주는 이유는 blur이벤트는 포커스를 잃을때 동작하므로 값이 변경되지 않지만, change이벤트는 값이 변경될때 발생하기 때문입니다.

아홉번째 항목은 유효성검사를 스킵하는경우에 수행하는 로직입니다. 다음 라인에서 유효성검사를 수행한다는 사실을 기억하면서 코드를 살펴봅시다. 먼저 isValid를 사용하는 경우 isValid를 업데이트하고 렌더링이 필요한경우 state의 업데이트를 전파하게 됩니다. 이때 watched상태이면 name프로퍼티만 전파하는것을 볼수 있는데, 앞서 살펴본적이 있던것처럼 강제 업데이트(무조건 리렌더링)을 의미합니다. 만약 watched상태가 아니라면 fieldState를 전달하는데, 이때는 변경된 필드가 있을때만 리렌더링이 발생합니다.

열번째 항목은 blur이벤트가 아니고, watch상태라면 formState를 곧바로 업데이트합니다. 왜냐하면 watch상태는 필드의 값이 변경되면 반드시 업데이트를 시켜주어야하기 때문입니다.

validation 수행

여기부터는 변경에서 두번째로 중요한(첫번째로 중요한 작업은 폼에 값을 저장하는것이었습니다.) 유효성 검사를 수행하는 방식을 살펴볼것입니다.

RHF을 사용해보셨다면 아시겠지만, RHF에서 유효성 검사를 수행하는 방법은 크게 두가지입니다. register메서드의 option으로 min, max등의 네이티브 프로퍼티를 넘기거나, 조금더 복잡한 유효성 검사가 필요한 경우 resolver옵션에 커스텀 resovler를 넘겨 수행하게됩니다.

분기문에 의해 두가지 케이스가 분리되어 실행되므로 두가지를 나누어 살펴보겠습니다. 여담이지만, 이러한 조건분기로 인해 커스텀 resovler와 네이티브 프로퍼티를 모두 명시하는경우 커스텀 resovler의 우선순위가 높아서 커스텀resovler만 실행 되게 됩니다.

resolver가 존재하는 경우

resolver는 유효성 검사를 수행할때 조건을 보다 직관적으로 명시할수 있도록 도와주는 유효성 검사 헬퍼함수입니다. joi, zod, yup등 여러 라이브러리들이 있지만 이들은 모두 값을 넣으면 에러객체가 반환되는 동일한 인터페이스를 가지고 있기에 어떤 라이브러리를 사용하더라도 문제가 없습니다.

const schema = yup
  .object()
  .shape({
    name: yup.string().required(),
    age: yup.number().required(),
  })
  .required()

const App = () => {
  const { register, handleSubmit } = useForm<Inputs>({
    resolver: yupResolver(schema), // yup, joi and even your own.
  })

  return (
    <form onSubmit={handleSubmit((d) => console.log(d))}>
      <input {...register("name")} />
      <input type="number" {...register("age")} />
      <input type="submit" />
    </form>
  )
}

이처럼 resolver를 명시하였을때는 어떤 방식으로 유효성검사가 처리되는지 살펴보겠습니다.


// 1. resolver 옵션으로 넘어온 함수 실행하기
const { errors } = await _executeSchema([name]);

// 2. validation 사후 처리
_updateIsFieldValueUpdated(fieldValue);

if (isFieldValueUpdated) {
  const previousErrorLookupResult = schemaErrorLookup(
    _formState.errors,
    _fields,
    name
  );
  const errorLookupResult = schemaErrorLookup(
    errors,
    _fields,
    previousErrorLookupResult.name || name
  );

  error = errorLookupResult.error;
  name = errorLookupResult.name;

  isValid = isEmptyObject(errors);
}

resolver 옵션으로 넘어온 함수 실행하기

가장 먼저 수행하는 작업은 resolver 옵션으로 넘어온 커스텀 resovler를 실행하는 _excuteSchema함수를 실행하는것입니다.

const _executeSchema = async (name?: InternalFieldName[]) => {
  // 유효성 업데이트 시작 업데이트
  _updateIsValidating(name, true);
  const result = await _options.resolver!(
    _formValues as TFieldValues,
    _options.context,
    getResolverOptions(
      name || _names.mount,
      _fields,
      _options.criteriaMode,
      _options.shouldUseNativeValidation
    )
  );
  // 유효성 업데이트 종료 업데이트
  _updateIsValidating(name);
  return result;
};

함수가 하는일은 간단한데.options.resolver를 실행하고, 비동기로 실행되는 해당함수의 앞뒤로 _updateIsValidating함수를 이용해 로딩을 걸어줍니다.

_options.resolver의 첫번째 인자로 들어가는 값은 폼이 내부적으로 관리하는 _formValues이고, 두번째 값은 유효성 검사시 사용할 context 값입니다. 세번째는 resolver 실행시 필요한 옵션입니다. 간략하게 첫번째 인자인 _formValues를 넘겨 유효성검사를 수행하고 에러객체를 반환한다 정도로 정리하면 되겠습니다.

validation 사후 처리

이제 다시 함수로 돌아가겠습니다. 다음 라인에서는 _updateIsFieldValueUpdated함수를 실행합니다. 이로인해 isFieldValueUpdated변수의 값이 업데이트됩니다. 우리가 이전에 값을 설정했기때문에 이 값은 true가 됩니다. 이 값이 false가 되려면, validation을 업데이트하고있는 와중에 _fieldValue가 변경되어야합니다. 이러면 유효성검사를 계속해서 수행할 필요가 없기때문에 스킵하게됩니다. 이 또한 불필요한 작업을 추가적으로 수행하지 않는것으로 이해하시면 되겠습니다.

isFieldValueUpdatedtrue이므로 if문 내부를 살펴봅시다. 여기서는 schemaErrorLookup함수를 사용하여 previousErrorLookupResulterrorLookupResult를 설정하게되는데, 이 함수는 에러 객체에서 특정 name의 에러를 찾는 함수입니다. 간단하게 생각하면 get함수를 사용하여 error객체를 조회하면 될것 같지만, 여러 이름을 전달하는 경우 (name1.name2와 같이 사용) 때문에 이러한 함수가 필요합니다. 중요한 함수는 아니기에 내부동작이 궁금하신분은 주석을 참고하여 살펴보시면 좋을것 같습니다.

export default function schemaErrorLookup<T extends FieldValues = FieldValues>(
  errors: FieldErrors<T>,
  _fields: FieldValues,
  name: string,
): {
  error?: FieldError;
  name: string;
} {
  // 에러 객체에서 name에 해당하는 에러 추출
  const error = get(errors, name);

  // 에러가 있거나 name이 키라면 조기 반환
  if (error || isKey(name)) {
    return {
      error,
      name,
    };
  }

  // 여러 이름을 넘긴경우 처리
  const names = name.split('.');

  while (names.length) {
    const fieldName = names.join('.');
    const field = get(_fields, fieldName);
    const foundError = get(errors, fieldName);

    if (field && !Array.isArray(field) && name !== fieldName) {
      return { name };
    }

    if (foundError && foundError.type) {
      return {
        name: fieldName,
        error: foundError,
      };
    }

    names.pop();
  }

  return {
    name,
  };
}

그리고 error, name, isValid를 업데이트합니다.

resolver 없을때

// 1. error 객체 생성 및 할당
_updateIsValidating([name], true);
error = (
  await validateField(
    field,
    _formValues,
    shouldDisplayAllAssociatedErrors,
    _options.shouldUseNativeValidation
  )
)[name];
_updateIsValidating([name]);

// 2. _updateIsFieldValueUpdated 업데이트 및 이에 따라 로직 실행
_updateIsFieldValueUpdated(fieldValue);

// 3. 
if (isFieldValueUpdated) {
  if (error) {
    isValid = false;
  } else if (_proxyFormState.isValid) {
    isValid = await executeBuiltInValidation(_fields, true);
  }
}

앞서 resolver를 적용하는 케이스와 로직이 유사합니다. validateField함수를 실행해 에러객체를 반환하고 그사이에 _updateIsValidating함수를 실행하여 로딩을 걸어줍니다. 이후 _updateIsFieldValueUpdated함수를 실행하여 isFieldValueUpdated 변수를 업데이트 한뒤 isValid를 업데이트 합니다. 네이티브 유효성검사를 수행하는 validateField의 경우 지면 관계상 너무 길어 포함시키지 않았지만, min,max등 여러 조건에 대해 케이스별로 처리하는 로직이 포함되어있습니다. 혹시 궁금하시다면 실제 로직을 찾아보시면 좋을것 같습니다.

연관된 폼 요소트리거 및 에러 렌더링

가장 마지막 조건문은 isFieldValueUpdated 즉, 필드 값이 업데이트 되었을때 의존성있는 요소와 에러를 렌더링하는 로직입니다. 두가지 항목을 나누어 살펴보겠습니다.

if (isFieldValueUpdated) {
  // 1. deps 에 명시된 의존성이 있는 요소 트리거
  field._f.deps &&
    trigger(
      field._f.deps as FieldPath<TFieldValues> | FieldPath<TFieldValues>[]
    );
  
  // 2. shouldRenderByError 함수 실행
  shouldRenderByError(name, isValid, error, fieldState);
}

deps

deps에는 이 필드가 변경되었을때 변경되어야할 다른 필드를 명시하게됩니다. 따라서 해당 필드가 있을때는 trigger메서드를 이용해 deps 내부에 명시한 필드의 유효성을 다시 계산합니다.

trigger 메서드 또한 지면 관계상 살펴보지는 않고 넘어가지만, 해당 메서드는 사용자가 직접 사용할수 있는 메서드로 특정 필드의 유효성검사를 수행할수 있도록 해주는 함수입니다.

shouldRenderByError

shouldRenderByError함수명을 보면 에러와 함께 리렌더링 하는 함수로 추측되는데, 이 사실이 맞는지는 함수를 분석하면서 확인해보겠습니다.

const shouldRenderByError = (
  name: InternalFieldName,
  isValid?: boolean,
  error?: FieldError,
  fieldState?: {
    dirty?: FieldNamesMarkedBoolean<TFieldValues>;
    isDirty?: boolean;
    touched?: FieldNamesMarkedBoolean<TFieldValues>;
  }
) => {
  // 1. 사용중인 변수
  const previousFieldError = get(_formState.errors, name);
  // isValid를 사용중이고, isValid값이 불리언이고, 이전 valid값과 다른경우에만 valid를 업데이트합니다.
  const shouldUpdateValid =
    _proxyFormState.isValid &&
    isBoolean(isValid) &&
    _formState.isValid !== isValid;

  // 2. 에러 업데이트
  if (props.delayError && error) {
    delayErrorCallback = debounce(() => updateErrors(name, error));
    delayErrorCallback(props.delayError);
  } else {
    clearTimeout(timer);
    delayErrorCallback = null;
    error
      ? set(_formState.errors, name, error)
      : unset(_formState.errors, name);
  }

  // 3. 에러 발행
  if (
    (error ? !deepEqual(previousFieldError, error) : previousFieldError) ||
    !isEmptyObject(fieldState) ||
    shouldUpdateValid
  ) {
    const updatedFormState = {
      ...fieldState,
      ...(shouldUpdateValid && isBoolean(isValid) ? { isValid } : {}),
      errors: _formState.errors,
      name,
    };

    _formState = {
      ..._formState,
      ...updatedFormState,
    };

    _subjects.state.next(updatedFormState);
  }
};

첫번째 항목에서는 함수내부에서 사용할 변수를 살펴보겠습니다. previousFieldError에는 _formState.errors에서 name에 해당하는 에러를 꺼내어 저장해둡니다. 이미 유효성검사를 통해 에러를 만들어내었더라도 아직 저장하지 않았기에 이전에 발생한 에러입니다. shouldUpdateValid의 경우 isValid를 사용하고 있고, isValidboolean이면서, 이전값과 다른경우 업데이트 가능하도록 값이 설정됩니다.

두번째 항목은 _formState.errorserror인자가 있다면 name의 에러를 업데이트 하고 없다면 제거합니다. 이때 delayError옵션 관련 로직이 있습니다. 해당 옵션은 에러 발생을 지연시키는 로직으로, 만약 해당 옵션이 켜져있다면 delayErrorCallback에 디바운스를 걸고 updateErrors를 넣어둔뒤 한번 실행해줍니다. 추후 onChange가 다시 실행되면 앞서 살펴본것처럼 이함수가 다시 실행되어 이라인까지 와야 또다시 실행이가능합니다.이로인해 else문에서 클리어하는 로직이 있는것을 알 수 있습니다.

네번째는 에러가 존재하면서 이전 에러와 다르거나, 에러가 없으면서 이전 필드 에러가 있거나, dirty, isDirty, touched, vaild가 업데이트 되어야한다면 updatedFormState를 만들고 이를 _formState에 반영한뒤 변경사항을 발행합니다.

마치며

onChange의 동작을 살펴보았습니다. _formValue에 값을 설정하고, 유효성검사를 수행하는 두가지 작업 뿐만 아니라 리렌더링과 연산을 최소한으로 발생시키기 위한 여러 작업들도 이해하실수 있으셨으면 좋겠습니다.

다음은 useForm의 마지막, 제출하는 단계를 살펴보겠습니다.

참고자료

input_types