흔하게 react hook form을 이용해 폼을 개발하던 중... 이상한 현상을 포착했다.
useForm 의 리턴값인 form을 props로 넘겨 받고, form.formState.isDirty 값을 update submit 함수 제출 전에 조건을 걸어서, 폼을 수정하지 않았으면 submit을 방지하려고 하였다.
const onSumbit = () => {
if (!form.formState.isDirty) return;
... 제출 함수 로직 ...
};
그런데 놀랍게도 폼을 건들이지 않았는데도 (isDirty = false), 함수의 제출은 잘 작동했다. 뭐지? 싶어서, 컴포넌트 안에 console.log(form.formStatus.isDirty) 를 출력해보았다. 그런데 그제서야 onSubmit이 의도대로 잘 작동하는 것이었다...! 뭐지? 싶어서 다시 console.log 를 지워보니, 원상태로 돌아가 다시 작동하지 않았다.
아무래도 react hook form 내부 상태의 업데이트 로직에 원인이 있는 듯 하여 깊게 탐구해보고자 한다.
formState는 Proxy로 감싸져 있어, 컴포넌트의 렌더링 과정에서 특정 상태값 (isDirty, isSubmitted) 등을 "구독"하지 않으면 해당 값의 변경이 일어나도 리렌더링을 트리거하지 않기 때문이다. 왜 이러한 현상이 발생할까?
React Hook Form의 가장 큰 장점 중 하나는 불필요한 리렌더링을 최소화하여 성능을 극대화 하는것이다. 만약 입력 필드가 20개인 폼에서 키를 한번 누를 때마다 전체 폼 컴포넌트가 리렌더링 된다면 매우 비효율적일 것이다. React Hook Form은 이 문제를 해결하기 위해 Proxy 패턴을 사용하여 formState를 관리한다.
왜 console.log(formState.isDirty) 를 사용하면 잘 작동하는지, 원리를 살펴보자.
formState는 그냥 객체가 아니다.formState는 내부적으로 js의 proxy 객체이다. 이 Proxy는 어떤 속성에 접근하지는 감시하는 감시자 역할을 한다.
렌더링 중 접근은 "구독" 을 의미한다. 컴포넌트가 렌더링될 때, jsx 내부나 useEffect 등에서 formState.isDirty 와 같은 특정 값에 접근하면, React Hook Form의 Proxy는 아, 이 컴포넌트는 isDirty 값의 변화에 관심이 있구나 라고 인지하고 해당 컴포넌트를 isDirty 상태의 구독자로 등록한다.
구독자에게만 알림 발송
이후 사용자가 폼 필드를 수정하여 isDirty 상태가 false에서 true로 바뀌면, React Hook Form은 오직 isDirty를 "구독" 한 컴포넌트에게만 상태가 바뀌었으니 리렌더링하라는 신호를 보낸다.
공식문서에서도 확인할 수 있다.
formState is wrapped in a Proxy to reduce rerenders. You must explicitly read the formState fields you want to track so that React Hook Form knows to subscribe to them.
나는 분명 onSubmit 함수안에 조건문으로 if (!form.formState.isDirty) return; 를 넣었다. 이러면 분명 구독한 것 아니야? 근데 왜 안돼? 라고 생각할 수 있고, 나역시 그렇게 생각했다...
하지만 중요한 것은, onSubmit 버튼을 클릭했을 때만 동작하는 함수라는 것이다. 즉, 렌더링 과정에서 form.formState.isDirty는 아무도 접근하지 않는다. 즉, 저 onSubmit 을 사용하는 컴포넌트는 isDirty를 구독하진 않는것이다.
즉, onSubmit 함수는 컴포넌트의 최초 렌더링 시점에 생성되고, 그때의 isDirty 는 당연히 초기값인 false 값을 가진 forState를 클로저로 가진다.
그렇기에 사용자가 입력 필드를 수정해도, isDirty 를 구독하지 않았기 때문에 리렌더링을 트리거하지 않고, submit 버튼을 눌러도 여전히 isDirty는 false가 되게 되는것이다.
그리고, console.log(formState.isDirty) 가 있었을 때 잘 작동했던 이유는, onSubmit 바깥의, 그리고 컴포넌트 안에 console.log(formState.isDirty) 구문이 있었고, 이 뜻은 해당 컴포넌트가 isDirty 를 구독하여 값이 변경할 때 마다 리렌더링을 한다는 뜻이었다. 그러기에 onSubmit 함수도 새로 생성되기에, isDirty 가 최신의 값을 잘 반영한 formState를 클로저로 갖는 것이다.
사실 해결방법은 간단하다. 위의 내용에서 설명했듯이, 접근하고 싶은 상태를 "구독" 하면 된다.
만약 form 을 props로 넘겨받았다면 아래와 같이 구조분해할당을 해주면 된다
const { formState: { isDirty, isValid } } = form;
혹은 useFormState 훅을 사용해여 구독하는 방법도 있다.
import { useFormState } from 'react-hook-form';
const { isDirty } = useFormState({ control: form.control });
요약하자면, RHF의
formState는Proxy이며, 성능을 위해 렌더링 시 구독된 상태에 대해서만 리렌더링을 유발한다. console.log는 렌더링 중에isDirty값을 읽어 우연히 구독을 만들었던 것이다. 이에 가장 좋은 해결책은const { formState: { isDirty } } = form;처럼 구조분해 할당을 통해 명시적으로 상태를 구독하고, disabled 속성처럼 UI에 직접 반영하는 것이다.