react-hook-form 최적화의 비밀

이 글은 react-hook-form v7.5.5 기준으로 작성했습니다.
시작하기전에
대부분의 서비스에서 사용자와 상호작용이 있는 UI를 구성 할 때 입력 필드를 포함한 폼(form) 구성을 하게 됩니다. 예시로 회원가입을 할 때 이메일과 이름, 그리고 검색 기능에서 검색 키워드를 입력하는 input등이 있습니다.
그리고 react에서는 변경 가능한 값인 상태(state)를 다루게 되는데 이때 사용자의 입력에 의한 값의 변화를 효율적으로 업데이트하고 유효성 검사 수행과 같은 여러 목적을 위해 react-hook-form
과 같은 라이브러리를 사용하곤 합니다.
본 글에서는 react-hook-form에서 사용자와 상호작용과 form의 상태를 추적하기위해 사용하는 formState 객체에 대해 이야기 드리고자합니다.
간단한 이메일 유효성 검증 예제
react-hook-form과 zod를 사용한 예제 코드를 먼저 보여드리겠습니다.
아래 코드는 이메일을 입력하고 버튼을 눌렀을때 유효한 이메일 형식인지 검증을 수행하는 간단한 코드입니다.
zod란? schema를 선언하고 검증하는 라이브러리
import { zodResolver } from '@hookform/resolvers/zod';
import { useForm } from 'react-hook-form';
import { z } from 'zod';
type EmailForm = {
email?: string;
};
export const InputValidationSchema = z.object({
email: z.string().email('올바른 이메일 형식을 입력하세요').optional(),
});
function App() {
const methods = useForm<EmailForm>({
resolver: zodResolver(InputValidationSchema),
mode: 'all',
defaultValues: {
email: 'abc@naver.com',
},
});
const requestVerificationCode = () => {
if (validateEmail()) {
console.log('done');
} else {
console.log('error');
}
};
const validateEmail = () => {
const isInValid = methods.getFieldState('email').invalid;
return !isInValid && methods.formState.isValid;
};
console.log(methods.formState.isValid);
return (
<form>
<input
{...methods.register('email')}
placeholder={'email input'}
/>
<button onClick={requestVerificationCode} type="button">
send code
</button>
</form>
);
}
export default App;
code sandbox
위 코드에서 console.log(methods.formState.isValid) 값의 출력 여부에 따라 requestVerificationCode()
함수의 결과가 달라지는것을 알고 계신가요?
console 객체의 메소드들은 일반적으로 부수효과(side effect) 없이, 값을 출력하는 pure function으로써 debugger와 더불어 원하는 시점에 특정 출력하여 개발과정중에 디버깅을 함으로써 용이하게 사용됩니다.
하지만, 위의 이메일 유효성 검사를 하는 코드에서는 console.log를 찍었을 뿐인데, 어떤 이유로 부수효과 발생할까요?
리마인드
pure function
동일한 입력값이 주어졌을 때 언제나 같은 값을 반환하고, 부수 효과(사이드 이펙트)를 만들지 않고 무결성을 보장하는 함수를 pure function이라 부릅니다. console.log, alert등 입력값을 그대로 출력하는 함수가 그렇습니다.
하지만 자바스크립트에서는 앞서 본 예제 코드와 같이 값을 출력했을때와 그렇지 않았을때 특정 값이 달라질 수 있는 예외적인 상황이 있습니다.
Proxy
JavaScript Proxy 객체는 ES6에 도입된 기능으로, 다른 객체(target)에 대한 작업을 가로채고 해당 작업의 기본 동작을 재정의 할 수 있도록 합니다.
Proxy는 target 객체를 감싸는 wrapper 역할을 하며, target에 대한 접근이나 수정을 시도할 때, 개발자가 정의한 특정 로직을 먼저 실행할 수 있게 해줍니다. Proxy 객체는 두 가지 주요 구성 요소로 이루어집니다.
- 첫 번째는 Proxy가 감쌀 대상 객체인 target
- 두 번째는 Proxy의 동작을 정의하는 handler 객체
handler 객체는 대상 객체에 대한 호출을 잡아내는 트랩(trap)이라고 불리는 함수들을 포함하며, 각 트랩은 특정 객체 내부 메서드(예: 속성 접근, 속성 할당, 함수 호출 등)의 호출을 가로채어 사용자 정의 동작을 수행하도록 합니다
const a = new Proxy(
{ value: 1 },
{
get(target, prop) {
return target.value++;
},
},
);
console.log(a.value); // 1
console.log(a.value); // 2
Object.defineProperty
Proxy와 유사하게 객체에 새로운 property를 정의하거나 이미 존재하는 property를 수정한 후, 해당 객체를 반환할 수 있습니다
let count = 1;
const a = { value: count };
Object.defineProperty(a, 'value', {
get() {
return count++;
},
});
console.log(a.value); // 1
console.log(a.value); // 2
useForm(formState) 톺아보기
간단한 예제코드와 더불어 관련된 리마인드가 끝났으니 본격적인 이야기를 시작하겠습니다.
react-hook-form은 기본적으로 uncontrolled components(비제어 컴포넌트) 방식을 제공하며, 그 중 사용자 입력 폼을 효율적으로 관리하기위해 제공하는 useForm이라는 hook이 있습니다.

useForm hook에서 반환하는 객체중에 form의 상태와 관련된 정보를 담고있는 formState
객체가 있습니다.
이는 useForm hook 내부에서 react의 useState로 선언되어 state(상태)로 관리되고있습니다.
UseFormProps

UseFormReturn


useRef hook은 리액트의 생명주기와 무관하게 동작하는 순수한 자바스크립트 객체를 제공하며
해당 객체는 리렌더링을 유발하지 않고 값을 유지할 수 있습니다.
formState는 렌더링 성능을 개선하고 특정 상태가 구독되지 않은 경우 추가로직을 건너뛰고 실제로 사용하는 프로퍼티만 추적합니다. 이를 구현하기위해 state를 직접 반환하지 않고, 선언해둔 _formControl(useRef)의 .current를 반환하고 있습니다.

그리고 formState의 값을 할당하는 getProxyFormState 메소드 내부에서는 get 프로퍼티를 재정의하고있으며, formState 객체의 프로퍼티들 loop내에서 Proxy로 래핑되고있습니다. 즉 특정 프로퍼티에 직접 접근될 때 flag를 변경하여 이후 실제 값을 추적하는 용도로 사용되는것입니다.

useIsomorphicLayoutEffect
typeof window !== ‘undefined’ ? React.useLayoutEffect : React.useEffect;
실제 formState를 변경하는 부분은 위와 같습니다.
useForm 내부에 useEffecuseIsomorphicLayoutEffect를 통해 control 객체를 구독하고, formState가 변할 때마다 callback(updateFormState)를 통해 formState를 최신 상태로 업데이트를 트리거합니다.

_subscribe의 구현은 위와 같습니다. shouldSubscribeByName을 통해 formState 객체에 포함된 키 중에서 proxyFormState[key] 값이 true면서 이전과 값이 달라졌는지를 판단한 뒤에 callback(updateFormState)을 호출하고있습니다.
요약하면 다음과 같습니다.
- formState는 useState를 통해 선언된 상태이지만, state를 직접 반환하지않고, ref.current를 반환합니다.
- 또한 formState는 Proxy로 래핑되어있으며, formState에 getter가 호출되었을때 proxyFormState 값이 true가 되어 update를 트리거할 수 있는 상태가 됩니다.
- 이후 값이 이전과 달라졌을때 updateFormState를 통해 상태 업데이트를 트리거합니다.
처음 console.log(formStae.isValid)를 통해 접근하여 해당 프로퍼티는 이미 변경을 구독하고 상태업데이트를 트리거할 준비가 된 프로퍼티라고 볼 수 있습니다.

https://react-hook-form.com/docs/useform/formstate
사실 공식 문서에 formState 규칙에 어느정도 기재된 내용입니다.
렌더링 성능을 향상시키고 특정 상태가 구독되지 않은 경우 추가 로직을 건너뛰기 위해 Proxy로 래핑되며, 상태 업데이트를 활성화하려면 렌더링전에 해당 상태 프로퍼티를 호출하거나 읽어야한다고 합니다.
결론
// ❌
const validateEmail = () => {
const isInValid = methods.getFieldState('email').invalid;
return !isInValid && methods.formState.isValid;
};
// ✅
const { isValid } = formState;
const validateEmail = () => {
const isInValid = methods.getFieldState('email').invalid;
return !isInValid && isValid;
};
react-hook-form을 통한 효율적이고 최적화된 폼 상태관리를 통해 얻게되는 이점은 부정할 수 없습니다.
다만, react-hook-form의 uncontrolled components 방식을 위한 내부구현을 알고 있지 않은 상태에서, 공식 레퍼런스를 읽지 않았다면 console.log(formState.isValid)와 같이 단순한 디버깅 코드를 출력했을때와 그렇지 않을때 다른 결과로 혼란이 생길 수 있습니다.
개인적으로 console.log와 같이 일반적으로 부수효과를 발생하지 않는 메소드의 사용 여부에 따라 비즈니스로직의 영향을 줄 수 있고, formState 객체에서 사용하려는 프로퍼티는 구조 분해 할당 등과 같이 직접 접근해야 한다는 점을 알고 있어야한다는 점은 조금 귀찮은 부분입니다.
물론 그럼에도 react-hook-form을 사용하면 얻게되는 이점은 많습니다.
서비스 내에서 서버 상태는 react-query로 위임하고, 유저의 인터랙션에 의한 form과 같은 상태의 변경은 react-hook-form을 통해 처리한다면 상태의 책임을 분리할 수 있습니다.
또한 zod yup과 같은 라이브러리와 결합해 form schema 검증을 쉽게 할 수 있으며, form 관련하여 직접 구현하지 않아도 사용할 수 있는 여러가지 유틸 기능들이 있습니다.
단순히 form 상태관리 라이브러로 사용하던중에 내부 구현을 조금은 깊이있게 들여다보는 좋은 기회가 되었습니다.