[TIL] 제어 컴포넌트와 비제어 컴포넌트 (feat. React Hook Form + Zod)

📌 들어가며

React의 폼 입력 관리 방식인 제어 컴포넌트(Controlled Component)비제어 컴포넌트(Uncontrolled Component)를 비교하고, 비제어 방식의 단점을 React Hook Form이 어떻게 보완하는지, 그리고 Zod와 함께 사용하면 좋은 이유를 정리한다.


📌 제어 컴포넌트 (Controlled Component)

React의 state가 입력값을 직접 제어하는 방식

function ControlledInput() { 
  const [value, setValue] = useState("");
  
  return ( 
    <input 
      value={value} 
      onChange={(e) => setValue(e.target.value)} 
    /> 
  ); 
}
  1. 입력값(value)이 항상 state에 의해 결정됨
  2. React 렌더링마다 값이 갱신되어 예측 가능하고 디버깅이 쉬움
  3. 대신 모든 입력 변경마다 리렌더링이 발생 성능 부담이 커질 수 있음

📌 비제어 컴포넌트 (Uncontrolled Component)

입력값을 React가 아닌 DOM이 직접 관리하는 방식

function UncontrolledInput() {
  const inputRef = useRef();
  const handleSubmit = () => alert(inputRef.current.value);
  
  return (
    <form>
      <input type="text" ref={inputRef} />
      <button onClick={handleSubmit}>Submit</button>
    </form>
  );
}
 
  1. value 대신 defaultValue를 사용
  2. 값 변경 시 React가 렌더링을 트리거하지 않음
  3. 간단하고 빠르지만, 다음과 같은 단점이 있음 👇

⚠️ 비제어 컴포넌트의 단점

  1. 값 추적 어려움 ref.current.value로 직접 접근해야 함
  2. 유효성 검사 불편 입력 이벤트마다 수동 검사 필요
  3. 초기값 변경 반영 안 됨 defaultValue는 첫 렌더링에만 적용됨
  4. React 데이터 흐름 위배 단방향 데이터 플로우가 깨짐
  5. 테스트 어려움 DOM 접근 기반 로직이라 테스트 불편
 

📌 비제어의 단점을 보완한 폼 라이브러리 (React Hook Form)

RHF는 비제어 컴포넌트 기반이지만, 내부적으로 ref와 Proxy를 조합해 React스럽게 제어 가능한 상태를 만든다.

💡 핵심 아이디어

  1. 각 필드를 register()로 등록해 내부적으로 ref를 추적
  2. 입력값이 변경되어도 React 전체가 리렌더링되지 않음
  3. 제출 시점에 getValues()로 모든 필드 값 수집
  4. errors, formState 등으로 폼 상태를 선언적으로 관리
import { useForm } from 'react-hook-form';

function Form() {
  const {
    register,
    handleSubmit,
    formState: { errors },
  } = useForm();
  
  const onSubmit = (data) => console.log(data);
  
  return (
    <form onSubmit={handleSubmit(onSubmit)}>
      <input {...register('email', { required: '이메일은 필수입니다' })} />
      {errors.email && <p>{errors.email.message}</p>} <button>제출</button>
    </form>
  );
}

✅ 비제어 컴포넌트의 단점을 RHF가 해결한 방식

  1. 값 추적 어려움 → register, getValues, watch로 접근
  2. 유효성 검사 어려움 → rules로 선언적 검증
  3. 초기값 변경 불가 → defaultValues로 선언적 초기화
  4. 리렌더링 과다 → ref 기반 관리로 성능 향상
 

🔍 Zod와 함께 쓰면 좋은 이유

RHF는 기본적으로 단순한 rule 기반 검증을 제공하지만, 복잡한 도메인 로직이나 다단계 스키마 검증이 필요한 경우 Zod를 결합하면 훨씬 강력해질 수 있다

✅ 이유 요약

  1. 타입 안전성 보장  TypeScript와 궁합이 뛰어남
  2. RHF와 완벽한 통합  @hookform/resolvers/zod로 손쉽게 연결 가능
 
import { z } from 'zod';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';

const schema = z.object({
  email: z.string().email('유효한 이메일을 입력하세요'),
  password: z.string().min(6, '비밀번호는 6자 이상이어야 합니다'),
});

function SignupForm() {
  const {
    register,
    handleSubmit,
    formState: { errors },
  } = useForm({ resolver: zodResolver(schema) });

  return (
    <form onSubmit={handleSubmit(console.log)}>
      <input {...register('email')} />
      {errors.email && <p>{errors.email.message}</p>}

      <input {...register('password')} type="password" />
      {errors.password && <p>{errors.password.message}</p>}

      <button>제출</button>
    </form>
  );
}

📚 오늘의 배움

  • React Hook Form은 비제어 방식의 효율성과 제어 방식의 일관성을 모두 취한 하이브리드 접근이다.
  • RHF와 Zod를 결합하면 타입 안정성과 선언적 검증까지 확보되어, 다양한 타입의 폼을 효율적이고 안정적으로 관리할 수 있는 실용적인 조합이 된다.