프런트엔드 개발에서 폼을 다루는 방식은 크게 제어 컴포넌트와 비제어 컴포넌트로 나뉩니다. 두 방식을 정확히 이해하면 성능 최적화와 유지보수 측면에서 큰 도움이 됩니다. 본 글에서는 두 방식의 차이를 설명하고, react-hook-form이 왜 비제어 컴포넌트 방식을 택했는지 살펴봅니다.
제어 컴포넌트는 입력 요소의 값을 React state로 관리합니다. 사용자가 타이핑할 때마다 onChange 이벤트가 호출되어 setState를 통해 값을 업데이트합니다. React는 상태 변화를 감지해 컴포넌트를 다시 렌더링하며, 이 과정에서 UI와 state가 항상 동기화됩니다.
export default function App() {
const [input, setInput] = useState("");
const onChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setInput(e.target.value);
};
return (
<div className="App">
<input value={input} onChange={onChange} />
</div>
);
}
위 예시는 사용자가 입력할 때마다 input 상태가 업데이트되며, 컴포넌트가 바로 재렌더링됩니다. 실시간 검증이나 다른 컴포넌트와의 즉각적인 데이터 공유가 필요할 때 유용합니다.
비제어 컴포넌트는 값을 DOM 레퍼런스(ref) 에서 직접 읽습니다. React state를 거치지 않기 때문에 입력 중에는 리렌더링이 발생하지 않습니다. 값이 필요한 시점(예: 제출 버튼 클릭)까지 DOM에 값을 보존하고 있다가, 필요할 때 ref.current.value로 꺼내 사용합니다.
export default function App() {
const inputRef = useRef<HTMLInputElement>(null);
const onSubmit = () => {
if (inputRef.current) {
console.log(inputRef.current.value);
}
};
return (
<div className="App">
<input ref={inputRef} />
<button type="button" onClick={onSubmit}>
전송
</button>
</div>
);
}
비제어 방식은 입력 중 불필요한 리렌더링을 최소화해 성능상 장점이 있습니다. 반면 입력 값이 실시간으로 동기화되지 않기 때문에, 입력 값 변화에 즉각 반응해야 하는 상황에서는 적합하지 않을 수 있습니다.
리액트의 대표적인 라이브러리react-hook-form은 기본적으로 비제어 컴포넌트 전략을 채택해 렌더링 수를 최소화합니다. 내부적으로 각 필드를 ref로 추적하므로 입력 이벤트마다 컴포넌트를 재렌더링하지 않습니다. 특히 필드 수가 많은 대형 폼에서 렌더링 비용을 크게 줄여 성능을 향상합니다.
다만 커스텀 UI 라이브러리(MUI, Ant Design 등)의 입력 요소는 내부적으로 state를 관리하기 때문에 제어 컴포넌트로 동작합니다. 이런 컴포넌트와 react-hook-form을 연동하려면 Controller를 사용합니다.
import { Controller } from "react-hook-form";
<Controller
name="username"
control={control}
defaultValue=""
render={({ field }) => <TextField label="ID" {...field} />}
/>
Controller는 라이브러리 컴포넌트의 value와 onChange를 react-hook-form에 위임해 제어 방식과 비제어 방식의 장점을 모두 취합니다.