- Published on
React Form 패턴: Controlled vs Uncontrolled Component
목차
Controlled Component
개념
React state가 입력 값의 "단일 진실 공급원(Single Source of Truth)"이 되는 방식입니다.
function ControlledInput() {
const [value, setValue] = useState('');
return (
<input
type="text"
value={value} // state가 값을 결정
onChange={(e) => setValue(e.target.value)} // 사용자 입력 → state 업데이트
/>
);
}
특징:
- 입력 값이 항상 React state에 저장됨
- 값의 변경은
onChange핸들러를 통해서만 가능 - React가 데이터의 흐름을 완전히 제어
기본 예시
function LoginForm() {
const [formData, setFormData] = useState({
email: '',
password: '',
});
const handleChange = (e) => {
const { name, value } = e.target;
setFormData((prev) => ({
...prev,
[name]: value,
}));
};
const handleSubmit = (e) => {
e.preventDefault();
console.log('제출된 데이터:', formData);
// API 호출 등...
};
return (
<form onSubmit={handleSubmit}>
<input
type="email"
name="email"
value={formData.email}
onChange={handleChange}
placeholder="이메일"
/>
<input
type="password"
name="password"
value={formData.password}
onChange={handleChange}
placeholder="비밀번호"
/>
<button type="submit">로그인</button>
</form>
);
}
실시간 검증
Controlled Component의 강력한 장점 중 하나는 실시간 검증입니다.
function EmailInput() {
const [email, setEmail] = useState('');
const [error, setError] = useState('');
const handleChange = (e) => {
const value = e.target.value;
setEmail(value);
// 실시간 검증
if (value && !value.includes('@')) {
setError('올바른 이메일 형식이 아닙니다');
} else {
setError('');
}
};
return (
<div>
<input type="email" value={email} onChange={handleChange} placeholder="이메일을 입력하세요" />
{error && <span style={{ color: 'red' }}>{error}</span>}
</div>
);
}
입력 값 제한
function PhoneInput() {
const [phone, setPhone] = useState('');
const handleChange = (e) => {
const value = e.target.value;
// 숫자와 하이픈만 허용
const formatted = value.replace(/[^\d-]/g, '');
// 최대 13자리 (010-1234-5678)
if (formatted.length <= 13) {
setPhone(formatted);
}
};
return (
<input
type="text"
value={phone}
onChange={handleChange}
placeholder="010-1234-5678"
maxLength={13}
/>
);
}
자동 포매팅
function CreditCardInput() {
const [cardNumber, setCardNumber] = useState('');
const handleChange = (e) => {
const value = e.target.value.replace(/\s/g, ''); // 공백 제거
const digits = value.replace(/\D/g, ''); // 숫자만 추출
// 4자리마다 공백 추가
const formatted = digits.match(/.{1,4}/g)?.join(' ') || digits;
if (digits.length <= 16) {
setCardNumber(formatted);
}
};
return (
<input
type="text"
value={cardNumber}
onChange={handleChange}
placeholder="1234 5678 9012 3456"
maxLength={19} // 16자리 + 공백 3개
/>
);
}
다중 입력 관리
function RegistrationForm() {
const [formData, setFormData] = useState({
username: '',
email: '',
password: '',
confirmPassword: '',
agreeToTerms: false,
});
const handleChange = (e) => {
const { name, value, type, checked } = e.target;
setFormData((prev) => ({
...prev,
[name]: type === 'checkbox' ? checked : value,
}));
};
const isPasswordMatch = formData.password === formData.confirmPassword;
const canSubmit =
formData.username &&
formData.email &&
formData.password &&
isPasswordMatch &&
formData.agreeToTerms;
return (
<form>
<input
name="username"
value={formData.username}
onChange={handleChange}
placeholder="사용자명"
/>
<input
type="email"
name="email"
value={formData.email}
onChange={handleChange}
placeholder="이메일"
/>
<input
type="password"
name="password"
value={formData.password}
onChange={handleChange}
placeholder="비밀번호"
/>
<input
type="password"
name="confirmPassword"
value={formData.confirmPassword}
onChange={handleChange}
placeholder="비밀번호 확인"
/>
{formData.confirmPassword && !isPasswordMatch && (
<span style={{ color: 'red' }}>비밀번호가 일치하지 않습니다</span>
)}
<label>
<input
type="checkbox"
name="agreeToTerms"
checked={formData.agreeToTerms}
onChange={handleChange}
/>
이용약관에 동의합니다
</label>
<button type="submit" disabled={!canSubmit}>
회원가입
</button>
</form>
);
}
Uncontrolled Component
개념
DOM 자체가 입력 값의 진실 공급원이 되는 방식입니다. React ref를 사용해서 필요할 때만 값을 가져옵니다.
function UncontrolledInput() {
const inputRef = useRef(null);
const handleSubmit = (e) => {
e.preventDefault();
console.log('입력된 값:', inputRef.current.value); // 제출 시점에만 값 읽기
};
return (
<form onSubmit={handleSubmit}>
<input type="text" ref={inputRef} defaultValue="" />
<button type="submit">제출</button>
</form>
);
}
특징:
- 입력 값이 DOM에 저장됨
defaultValue로 초기값 설정- ref를 통해 필요할 때 값에 접근
기본 예시
function SimpleForm() {
const nameRef = useRef(null);
const emailRef = useRef(null);
const handleSubmit = (e) => {
e.preventDefault();
const formData = {
name: nameRef.current.value,
email: emailRef.current.value,
};
console.log('제출된 데이터:', formData);
};
return (
<form onSubmit={handleSubmit}>
<input type="text" ref={nameRef} defaultValue="" placeholder="이름" />
<input type="email" ref={emailRef} defaultValue="" placeholder="이메일" />
<button type="submit">제출</button>
</form>
);
}
파일 업로드
파일 입력은 항상 Uncontrolled입니다. (보안상의 이유로 JavaScript에서 파일 입력의 값을 프로그래밍 방식으로 설정할 수 없음)
function FileUpload() {
const fileInputRef = useRef(null);
const [uploadedFile, setUploadedFile] = useState(null);
const handleSubmit = (e) => {
e.preventDefault();
const file = fileInputRef.current.files[0];
if (!file) {
alert('파일을 선택해주세요');
return;
}
setUploadedFile(file);
console.log('선택된 파일:', file.name, file.size);
// 실제 업로드 로직...
const formData = new FormData();
formData.append('file', file);
// fetch('/upload', { method: 'POST', body: formData })
};
return (
<form onSubmit={handleSubmit}>
<input type="file" ref={fileInputRef} accept="image/*" />
<button type="submit">업로드</button>
{uploadedFile && <p>선택된 파일: {uploadedFile.name}</p>}
</form>
);
}
포커스 제어
function SearchForm() {
const searchInputRef = useRef(null);
const handleClear = () => {
searchInputRef.current.value = ''; // 값 초기화
searchInputRef.current.focus(); // 포커스 이동
};
useEffect(() => {
// 컴포넌트 마운트 시 자동 포커스
searchInputRef.current.focus();
}, []);
return (
<div>
<input type="text" ref={searchInputRef} placeholder="검색어를 입력하세요" />
<button onClick={handleClear}>지우기</button>
</div>
);
}
FormData API 활용
Uncontrolled Component는 FormData API와 잘 어울립니다.
function ContactForm() {
const formRef = useRef(null);
const handleSubmit = (e) => {
e.preventDefault();
const formData = new FormData(formRef.current);
// FormData에서 값 추출
const data = {
name: formData.get('name'),
email: formData.get('email'),
message: formData.get('message'),
};
console.log('제출된 데이터:', data);
// 또는 직접 fetch로 전송
// fetch('/api/contact', { method: 'POST', body: formData })
};
return (
<form ref={formRef} onSubmit={handleSubmit}>
<input type="text" name="name" defaultValue="" placeholder="이름" />
<input type="email" name="email" defaultValue="" placeholder="이메일" />
<textarea name="message" defaultValue="" placeholder="메시지" />
<button type="submit">보내기</button>
</form>
);
}
두 방식의 비교
Controlled Component
장점:
- 실시간 검증 가능
- 입력 값 포매팅/제한 쉬움
- 조건부 렌더링 가능 (입력 값에 따라 UI 변경)
- 여러 컴포넌트에서 같은 state 공유 가능
- 디버깅 쉬움 (React DevTools로 state 확인)
단점:
- 매 입력마다 리렌더링 발생
- 보일러플레이트 코드 증가
- 성능 오버헤드 (큰 폼에서)
코드 예시:
// 장점: 실시간 검증 & 조건부 렌더링
function PasswordInput() {
const [password, setPassword] = useState('');
const strength = password.length < 6 ? 'weak' : password.length < 12 ? 'medium' : 'strong';
return (
<div>
<input type="password" value={password} onChange={(e) => setPassword(e.target.value)} />
<span>강도: {strength}</span>
{strength === 'weak' && <p style={{ color: 'red' }}>비밀번호가 너무 짧습니다</p>}
</div>
);
}
Uncontrolled Component
장점:
- 간단한 구현 (보일러플레이트 적음)
- 성능 우수 (리렌더링 없음)
- 기존 HTML 폼과 통합 쉬움
- 파일 업로드 등 특정 케이스에 필수
단점:
- 실시간 검증 어려움
- 입력 값 제어 불가
- 여러 컴포넌트에서 값 공유 어려움
- 디버깅 어려움 (DOM 직접 확인 필요)
코드 예시:
// 장점: 간단한 구현
function QuickFeedback() {
const inputRef = useRef(null);
const handleSubmit = (e) => {
e.preventDefault();
const feedback = inputRef.current.value;
if (!feedback.trim()) {
alert('피드백을 입력해주세요');
return;
}
console.log('피드백:', feedback);
inputRef.current.value = ''; // 초기화
};
return (
<form onSubmit={handleSubmit}>
<textarea ref={inputRef} placeholder="피드백을 남겨주세요" />
<button type="submit">보내기</button>
</form>
);
}
언제 어떤 것을 사용할까
Controlled Component를 사용해야 할 때
1. 실시간 검증이 필요할 때
function EmailSignup() {
const [email, setEmail] = useState('');
const isValid = /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email);
return (
<div>
<input type="email" value={email} onChange={(e) => setEmail(e.target.value)} />
{email && !isValid && <span>올바른 이메일을 입력하세요</span>}
<button disabled={!isValid}>가입하기</button>
</div>
);
}
2. 입력 값에 따라 UI가 변경될 때
function DynamicForm() {
const [userType, setUserType] = useState('personal');
return (
<div>
<select value={userType} onChange={(e) => setUserType(e.target.value)}>
<option value="personal">개인</option>
<option value="business">기업</option>
</select>
{userType === 'business' && <input type="text" placeholder="사업자등록번호" />}
</div>
);
}
3. 입력 값을 포매팅해야 할 때
function PriceInput() {
const [price, setPrice] = useState('');
const handleChange = (e) => {
const value = e.target.value.replace(/,/g, '');
if (/^\d*$/.test(value)) {
// 숫자만 허용
const formatted = Number(value).toLocaleString();
setPrice(formatted);
}
};
return <input type="text" value={price} onChange={handleChange} placeholder="가격 입력" />;
}
4. 여러 컴포넌트에서 같은 값을 사용할 때
function SearchPage() {
const [searchQuery, setSearchQuery] = useState('');
return (
<div>
<SearchInput value={searchQuery} onChange={setSearchQuery} />
<SearchResults query={searchQuery} />
<SearchSuggestions query={searchQuery} />
</div>
);
}
Uncontrolled Component를 사용해야 할 때
1. 간단한 폼일 때
function NewsletterSignup() {
const emailRef = useRef(null);
const handleSubmit = (e) => {
e.preventDefault();
const email = emailRef.current.value;
console.log('구독:', email);
};
return (
<form onSubmit={handleSubmit}>
<input type="email" ref={emailRef} placeholder="이메일" />
<button type="submit">구독하기</button>
</form>
);
}
2. 파일 업로드
function ImageUpload() {
const fileRef = useRef(null);
const handleUpload = () => {
const file = fileRef.current.files[0];
if (!file) return;
// 업로드 로직...
console.log('업로드할 파일:', file.name);
};
return (
<div>
<input type="file" ref={fileRef} accept="image/*" />
<button onClick={handleUpload}>업로드</button>
</div>
);
}
3. 비React 라이브러리와 통합할 때
function TextEditor() {
const editorRef = useRef(null);
useEffect(() => {
// 가상의 에디터 라이브러리 초기화
const editor = initializeEditor(editorRef.current);
return () => {
editor.destroy();
};
}, []);
return <div ref={editorRef} />;
}
4. 성능이 중요한 대용량 폼
function LargeForm() {
const formRef = useRef(null);
const handleSubmit = (e) => {
e.preventDefault();
const formData = new FormData(formRef.current);
// 50개 필드의 값을 한 번에 추출
const data = Object.fromEntries(formData);
console.log('제출:', data);
};
return (
<form ref={formRef} onSubmit={handleSubmit}>
{Array.from({ length: 50 }, (_, i) => (
<input key={i} name={`field${i}`} defaultValue="" />
))}
<button type="submit">제출</button>
</form>
);
}
실전 활용 패턴
혼합 사용
두 방식을 함께 사용하는 것도 가능합니다.
function MixedForm() {
// Controlled: 실시간 검증 필요
const [email, setEmail] = useState('');
const isEmailValid = /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email);
// Uncontrolled: 제출 시점에만 필요
const nameRef = useRef(null);
const messageRef = useRef(null);
const handleSubmit = (e) => {
e.preventDefault();
const data = {
name: nameRef.current.value,
email: email,
message: messageRef.current.value,
};
console.log('제출:', data);
};
return (
<form onSubmit={handleSubmit}>
<input type="text" ref={nameRef} placeholder="이름" />
<input
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="이메일"
/>
{email && !isEmailValid && <span style={{ color: 'red' }}>올바른 이메일을 입력하세요</span>}
<textarea ref={messageRef} placeholder="메시지" />
<button type="submit" disabled={!isEmailValid}>
보내기
</button>
</form>
);
}
초기값 설정
Controlled:
function EditProfile({ user }) {
const [name, setName] = useState(user.name);
const [bio, setBio] = useState(user.bio);
return (
<form>
<input value={name} onChange={(e) => setName(e.target.value)} />
<textarea value={bio} onChange={(e) => setBio(e.target.value)} />
</form>
);
}
Uncontrolled:
function EditProfile({ user }) {
const nameRef = useRef(null);
const bioRef = useRef(null);
return (
<form>
<input ref={nameRef} defaultValue={user.name} />
<textarea ref={bioRef} defaultValue={user.bio} />
</form>
);
}
폼 초기화
Controlled:
function ContactForm() {
const [formData, setFormData] = useState({ name: '', email: '', message: '' });
const handleReset = () => {
setFormData({ name: '', email: '', message: '' });
};
return (
<form>
{/* inputs... */}
<button type="button" onClick={handleReset}>
초기화
</button>
</form>
);
}
Uncontrolled:
function ContactForm() {
const formRef = useRef(null);
const handleReset = () => {
formRef.current.reset(); // HTML 폼의 reset() 메서드 사용
};
return (
<form ref={formRef}>
{/* inputs... */}
<button type="button" onClick={handleReset}>
초기화
</button>
</form>
);
}
동적 필드 추가
Controlled:
function DynamicFields() {
const [fields, setFields] = useState([{ id: 1, value: '' }]);
const addField = () => {
setFields([...fields, { id: Date.now(), value: '' }]);
};
const handleChange = (id, value) => {
setFields(fields.map((field) => (field.id === id ? { ...field, value } : field)));
};
return (
<div>
{fields.map((field) => (
<input
key={field.id}
value={field.value}
onChange={(e) => handleChange(field.id, e.target.value)}
/>
))}
<button onClick={addField}>필드 추가</button>
</div>
);
}
Uncontrolled:
function DynamicFields() {
const [fieldCount, setFieldCount] = useState(1);
const formRef = useRef(null);
const addField = () => {
setFieldCount((prev) => prev + 1);
};
const handleSubmit = (e) => {
e.preventDefault();
const formData = new FormData(formRef.current);
const values = Array.from(formData.values());
console.log('모든 값:', values);
};
return (
<form ref={formRef} onSubmit={handleSubmit}>
{Array.from({ length: fieldCount }, (_, i) => (
<input key={i} name={`field${i}`} defaultValue="" />
))}
<button type="button" onClick={addField}>
필드 추가
</button>
<button type="submit">제출</button>
</form>
);
}
폼 라이브러리와의 관계
React Hook Form (Uncontrolled 기반)
React Hook Form은 Uncontrolled 방식을 기반으로 하면서도 편리한 API를 제공합니다.
import { useForm } from 'react-hook-form';
function LoginForm() {
const {
register,
handleSubmit,
formState: { errors },
} = useForm();
const onSubmit = (data) => {
console.log(data);
};
return (
<form onSubmit={handleSubmit(onSubmit)}>
<input {...register('email', { required: true, pattern: /^\S+@\S+$/i })} />
{errors.email && <span>올바른 이메일을 입력하세요</span>}
<input type="password" {...register('password', { required: true, minLength: 6 })} />
{errors.password && <span>비밀번호는 최소 6자 이상이어야 합니다</span>}
<button type="submit">로그인</button>
</form>
);
}
장점:
- Uncontrolled의 성능 + Controlled의 편의성
- 리렌더링 최소화
- 내장 검증 기능
Formik (Controlled 기반)
Formik은 Controlled 방식을 기반으로 합니다.
import { useFormik } from 'formik';
function SignupForm() {
const formik = useFormik({
initialValues: {
email: '',
password: '',
},
validate: (values) => {
const errors = {};
if (!values.email.includes('@')) {
errors.email = '올바른 이메일을 입력하세요';
}
if (values.password.length < 6) {
errors.password = '비밀번호는 최소 6자 이상이어야 합니다';
}
return errors;
},
onSubmit: (values) => {
console.log(values);
},
});
return (
<form onSubmit={formik.handleSubmit}>
<input
type="email"
name="email"
value={formik.values.email}
onChange={formik.handleChange}
onBlur={formik.handleBlur}
/>
{formik.touched.email && formik.errors.email && <span>{formik.errors.email}</span>}
<input
type="password"
name="password"
value={formik.values.password}
onChange={formik.handleChange}
onBlur={formik.handleBlur}
/>
{formik.touched.password && formik.errors.password && <span>{formik.errors.password}</span>}
<button type="submit">회원가입</button>
</form>
);
}
장점:
- Controlled의 모든 기능
- 풍부한 검증 옵션
- Yup 같은 스키마 검증 라이브러리와 통합
마치며
선택 가이드
Controlled Component를 선택하세요:
- 실시간 검증이 필요할 때
- 입력 값에 따라 UI가 변경될 때
- 입력 값을 포매팅해야 할 때
- 여러 컴포넌트에서 같은 값을 사용할 때
Uncontrolled Component를 선택하세요:
- 간단한 폼일 때
- 파일 업로드가 필요할 때
- 제출 시점에만 값이 필요할 때
- 성능이 중요한 대용량 폼일 때
실무 팁
- 작게 시작: 간단한 폼은 Uncontrolled로 시작
- 필요에 따라 전환: 요구사항이 복잡해지면 Controlled로 전환
- 혼합 사용: 각 입력마다 적절한 방식 선택
- 라이브러리 고려: 복잡한 폼은 React Hook Form이나 Formik 사용
작성일: 2025-11-17 마지막 수정: 2025-11-17