내부 가변성(Interior Mutability)은 객체나 함수의 참조는 그대로인데 안에 숨은 상태가 바뀌는 성질이다. React의 메모이제이션은 "입력이 다른 참조인가"만 보기 때문에, 참조가 같으면 내용이 바뀌어도 바뀌지 않았다고 판단한다. React 공식 문서는 이를 "겉모습은 같은데 몰래 내용물을 바꾸는 상자"에 비유한다.
function Form() {
const { watch } = useForm()
// watch는 늘 같은 함수다 → deps가 안 바뀌어 처음 값에 얼어붙는다
const name = useMemo(() => watch('name'), [watch])
return <div>{name}</div>
}React Compiler와 만날 때
React Compiler는 입력 참조가 같으면 이전 결과를 재사용한다. 그래서 내부 가변성이 있는 API의 반환값을 메모하면 위 useMemo와 같은 일이 자동으로 생길 수 있다. 이를 막으려고 컴파일러는 알려진 비호환 API(react-hook-form의 watch 등)를 쓰는 컴포넌트를 컴파일하지 않고 건너뛴다. 앱은 깨지지 않지만 그 컴포넌트는 자동 메모 혜택을 못 받는다. eslint-plugin-react-hooks의 incompatible-library 규칙이 이런 곳을 경고로 알려 준다.
두 경우를 구분한다.
- 손으로 쓴
useMemo(..., [watch]): deps가 영영 안 바뀌어 값이 얼어붙는 버그 - 컴파일러가 감지한
watch(): 컴포넌트가 최적화 대상에서 빠질 뿐 동작은 맞다. 다만 감지에 기대는 것이라 감지가 빗나가는 코드 모양이면 얼어붙을 위험이 남는다
watch와 useWatch
watch() | useWatch({ control, name }) | |
|---|---|---|
| 구독 위치 | useForm을 부른 루트 | 이 훅을 부른 컴포넌트 |
| 다시 렌더되는 범위 | 폼 전체 | 호출한 컴포넌트만 |
| 컴파일러와의 관계 | 비호환 API로 건너뜀 | 바뀔 때 새 값으로 다시 렌더되어 호환 |
- 렌더에 쓰는 값은
useWatch로 구독한다. 공식 문서도 리렌더 범위를 훅 단위로 좁혀 성능이 낫다고 설명하고, React 문서의incompatible-library해결책도useWatch다 - 이벤트 핸들러나 Effect에서 한 번 읽는 값은 구독이 필요 없으니
getValues() - 폼 상태(dirty, errors) 표시는
useFormState - react-hook-form 쪽에서 컴파일러와의 올바른 동작을 다루는 추적 이슈는 아직 열려 있다(2026-10 기준). 라이브러리 업데이트를 기다리기보다
useWatch로 바꾸는 편이 확실하다
탈출구
당장 바꾸기 어려우면 그 컴포넌트 함수 첫 줄에 'use no memo'를 두어 컴파일러 대상에서 뺀다. 임시 조치로 쓰고, 원인 API를 구독형으로 바꾸면 지운다.
같은 함정은 렌더 중에 변경 가능한 객체를 직접 고치는 코드에서도 생긴다. 새 참조를 만드는 불변 업데이트가 메모이제이션의 전제다(React의 규칙과 렌더 순수성, 얕은 복사와 깊은 복사, memo·useMemo·useCallback). 외부 가변 저장소를 안전하게 구독하는 표준 방법은 useSyncExternalStore다.
출처: React - incompatible-library · React Hook Form - useWatch · react-hook-form #12298 - Correct behaviour for apps using react-compiler