React Hook Form에 첫 기여하기
문제 발견
react-hook-form에 기여를 하고 싶어 이슈를 들락날락 하다가 찾았습니다. React Hook Form 7.63.0 버전부터 발생한 버그였는데, reset() 메서드를 호출한 직후 watch() 메서드가 undefined를 반환하는 문제였습니다.
정상적인 상황이라면 reset()으로 폼 값을 초기화한 후 watch()를 호출하면 초기화된 값이 반환되어야 하는데, 특정 조건에서 undefined가 반환됐습니다.
특히 이 문제는 사용자가 필드와 상호작용하기 전에 reset()을 호출했을 때 발생했습니다.
원인 분석
React Hook Form의 내부 구조를 깊이 분석한 결과, 문제의 근본 원인을 찾을 수 있었습니다.
_state.mount의 역할
React Hook Form은 내부적으로 _state.mount라는 플래그를 사용하여 폼이 "마운트"되었는지 추적합니다. 이 플래그는 다음과 같은 역할을 합니다:
- 초기값: false
- 사용자가 필드와 상호작용(입력, 포커스 등)하면 true로 변경됨
- watch() 메서드가 어떤 값을 반환할지 결정하는 중요한 조건
버그가 발생한 이유
watch() 메서드 내부의 _getWatch 함수는 다음과 같은 로직으로 동작합니다:
// 간소화된 _getWatch 로직
function _getWatch(names, defaultValue) {
// _state.mount가 false면 defaultValue 반환
if (!_state.mount) {
return defaultValue;
}
// _state.mount가 true면 실제 폼 값 반환
return getFieldValue(names);
}
문제는 reset() 메서드에서 발생했습니다:
- 사용자가 필드와 상호작용하기 전에 reset()을 호출
- reset()은 내부적으로 폼 값을 업데이트하지만, _state.mount 는 여전히 false
- 이후 watch()를 호출하면 _getWatch가 _state.mount === false를 확인
- 업데이트된 폼 값 대신 defaultValue(undefined)를 반환
재현 조건
// 버그 재현 코드
const { reset, watch } = useForm({
defaultValues: { name: "initial" },
});
// 사용자가 필드와 상호작용하기 전에 reset 호출
reset({ name: "updated" });
// undefined 반환! (기대값: 'updated')
console.log(watch("name"));
이 버그는 7.63.0 버전의 리팩토링 과정에서 reset() 함수가 _state.mount를 적절히 업데이트하지 않으면서 발생했습니다.
버그 발생 플로우
flowchart TD
A[useForm 초기화] --> B[_state.mount = false]
B --> C{사용자 액션}
C -->|필드 상호작용 전| D[reset 호출]
C -->|필드 입력| E[_state.mount = true]
D --> F[폼 값 업데이트]
F --> G[_state.mount는 여전히 false]
G --> H[watch 호출]
H --> I{_state.mount 체크}
I -->|false| J[❌ undefined 반환]
E --> K[watch 호출]
K --> L{_state.mount 체크}
L -->|true| M[✅ 실제 값 반환]
해결 방법
해결책은 reset() 함수 내부에서 _state.mount의 값을 결정하는 조건부 로직을 수정하는 것이었습니다.
기존 코드 (버그 발생)
// createFormControl.ts의 _reset 함수 내부
_state.mount =
!_proxyFormState.isValid ||
!!keepStateOptions.keepIsValid ||
!!keepStateOptions.keepDirtyValues;
기존 로직은 폼의 validation 상태와 keep 옵션에만 의존하고 있었습니다. 이로 인해 reset()으로 값을 설정해도 _state.mount가 false로 남아있는 경우가 발생했습니다.
수정된 코드
_state.mount =
!!keepStateOptions.keepDirtyValues ||
(!_options.shouldUnregister && !isEmptyObject(values));
수정된 로직은 더 간결하면서도 핵심적인 조건에 집중합니다:
- dirty 값을 유지하도록 설정되어 있거나 (
keepDirtyValues
) - 필드 등록을 해제하지 않으면서 값이 비어있지 않은 경우 (
!shouldUnregister && !isEmptyObject(values)
)
특히 두 번째 조건이 핵심입니다. reset()으로 값을 설정하면 values가 비어있지 않게 되므로, _state.mount가 true로 설정되어 watch()가 올바른 값을 반환하게 됩니다.
테스트 케이스 추가
또한 이 버그가 재발하지 않도록 테스트 케이스도 추가했습니다:
it('should return correct value after reset when _state.mount is false', () => {
const { watch, reset, getValues } = useForm({
defaultValues: { name: 'initial' }
});
reset({ name: 'updated' });
expect(watch('name')).toBe('updated');
expect(getValues('name')).toBe('updated');
});
리뷰 과정
PR을 올린 후 메인테이너인 @bluebill1049님으로부터 빠른 피드백을 받았습니다.
처음에는 dirty values와 valid state에 대한 잠재적인 리그레션에 대한 우려가 있었습니다. 이는 매우 합리적인 지적이었고, 기존 코드의 조건부 마운트 체크를 유지하면서도 버그를 해결할 수 있도록 솔루션을 개선했습니다.
메인테이너의 빠르고 명확한 피드백 덕분에 금방 문제를 해결할 수 있었고, 결과적으로 PR은 머지됐습니다.
참고 링크: