실전 웹 앱의 절반은 폼. 로그인·회원가입·검색·결제·설정. React 에서 폼 처리는 두 가지 큰 갈래 — controlled (state 가 input 값을 직접 추적) 와 uncontrolled (DOM 이 자체 관리, React 는 필요할 때만 읽음). 둘 다 알아야 한다.
이번 10편에서 두 패턴 비교 + 다중 input 의 표준 useState 패턴 + 제출 처리 + 기본 검증까지. 21편에서는 Zod + React Hook Form 같은 라이브러리 패턴으로 확장.
1. controlled — useState 가 single source of truth
가장 흔한 패턴. input 의 value 를 state 에 묶고 onChange 로 갱신.
function LoginForm() {
const [email, setEmail] = useState('');
const [pwd, setPwd] = useState('');
return (
<form>
<input
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
/>
<input
type="password"
value={pwd}
onChange={(e) => setPwd(e.target.value)}
/>
</form>
);
}
매 키 입력마다 setState → re-render. 처음엔 비효율 같지만 React 가 빠르고, 입력 도중 검증·자동 변환·미리보기를 자유롭게 할 수 있는 게 진짜 가치.
왜 single source of truth 인가 — state 만 보면 input 값이 보장됨. 외부에서 reset 버튼으로 setEmail('') 하면 input 도 자동 클리어. DOM 따로 추적할 필요 0. 디버깅·테스트 압도적 우위.
2. uncontrolled — useRef 로 필요할 때 읽기
매 입력마다 state 갱신이 부담스러운 경우 (10000자 textarea 등) 또는 input 값이 바뀌어도 다른 UI 가 안 바뀌는 경우. useRef 로 DOM 참조를 잡고 제출 시점에 한 번 읽는다.
import { useRef } from 'react';
function FeedbackForm() {
const textareaRef = useRef(null);
const handleSubmit = (e) => {
e.preventDefault();
const text = textareaRef.current.value;
fetch('/api/feedback', { method: 'POST', body: text });
};
return (
<form onSubmit={handleSubmit}>
<textarea ref={textareaRef} defaultValue="" />
<button>전송</button>
</form>
);
}
value 대신 defaultValue 사용 — DOM 이 자체 관리. React 는 ref 를 통해 필요할 때만 값을 가져온다. 재렌더 0.
3. 두 패턴 한 줄 비교
| 관점 | controlled | uncontrolled |
| 값 추적 | useState (매 입력 재렌더) | DOM (재렌더 0) |
| 실시간 검증·변환 | ★ 쉽다 (state 보고) | 어렵다 (DOM 이벤트 직접) |
| 외부 reset/제어 | ★ setState 한 줄 | ref.current.value = '' (수동) |
| 대량 텍스트 | 매 입력 부담 | ★ 부담 0 |
| 파일 input (file upload) | 불가 (브라우저 보안) | ★ 필수 |
실전 — 99% controlled. file input·대용량 텍스트 만 uncontrolled. 헷갈리면 controlled 부터 시작.
4. 다중 input — 객체 state 패턴
회원가입 폼처럼 input 이 5개+ 면 useState 5개는 지저분. 객체 하나로 묶고 name 속성 활용.
function SignupForm() {
const [form, setForm] = useState({
email: '',
password: '',
name: '',
age: ''
});
const handleChange = (e) => {
const { name, value } = e.target;
setForm(prev => ({ ...prev, [name]: value })); // 6편 함수형 + 동적 키
};
const handleSubmit = (e) => {
e.preventDefault();
// 검증
if (!form.email.includes('@')) return alert('이메일 형식 오류');
if (form.password.length < 8) return alert('비밀번호 8자 이상');
// 전송
fetch('/api/signup', { method: 'POST', body: JSON.stringify(form) });
};
return (
<form onSubmit={handleSubmit}>
<input name="email" type="email" value={form.email} onChange={handleChange} />
<input name="password" type="password" value={form.password} onChange={handleChange} />
<input name="name" value={form.name} onChange={handleChange} />
<input name="age" type="number" value={form.age} onChange={handleChange} />
<button>가입</button>
</form>
);
}
핵심은 handleChange 한 함수로 모든 input 처리. name 속성을 객체 키로 사용하는 동적 키 ([name]) 가 패턴. input 개수가 늘어도 핸들러는 그대로.
검증 라이브러리 사전 예고 — 검증 로직이 5개 넘어가면 직접 if 분기 대신 Zod + React Hook Form 조합이 표준. 회사 stack 도 그렇다 (Zod·RHF 둘 다 stack 에 있음). 21편에서 정식 도입. 그때까지는 위 패턴이면 충분.
입문/기초 단계 마무리 단원이다. 11편부터 중급 — useEffect 로 side effect (데이터 fetch·구독·timer) 다루기. 진짜 인터랙티브 앱의 시작.
다음 글
React 교재 11편 — useEffect 로 side effect 다루기. 언제 실행되나, dependency array, cleanup function, 무한 루프 함정.