폼 다루기 (controlled inputs)
리액트에서 폼을 다루는 정석 패턴인 controlled component를 살펴봅니다. textarea · select · checkbox · radio 모두 같은 모델로 다루는 법까지.
8장까지 리액트의 핵심 빌딩 블록을 모두 봤습니다. 컴포넌트, props, state, 이벤트, 조건부 / 리스트 렌더링까지 다 다뤘습니다. 본 챕터는 1부의 마무리입니다. 거의 모든 앱에 등장하는 폼 (form) 처리를 정석 패턴으로 다지겠습니다.
본 챕터의 controlled 모델은 19장 (이벤트와 폼 타이핑)에서 TypeScript로 굳히고, 27장 (Server Actions와 폼)에서 새 모델(<form action={fn}> + useActionState)로 한 번 더 확장됩니다. 본 챕터의 패턴을 단단히 잡아 두면 그 뒷장이 가볍게 읽힙니다.
입력 요소를 다루는 두 가지 방식 #
리액트에서 입력 요소를 다루는 방식은 크게 두 가지로 나뉩니다.
- Controlled Component (제어 컴포넌트) — 입력 값의 기준을 리액트 state에 두고, 화면의 입력이 그 state를 따라가게 하는 방식
- Uncontrolled Component (비제어 컴포넌트) — 입력 값을 DOM 자체에 맡기고, 필요할 때
ref로 꺼내 쓰는 방식
리액트에서는 Controlled Component가 정석이고, 본 챕터도 거기에 집중하겠습니다. 비제어 방식은 18장 (hooks 타이핑)에서 useRef를 다룰 때 다시 짚을 기회가 있습니다.
Controlled Component란 #
이미 6장과 8장에서 썼던 패턴입니다. value와 onChange를 한 쌍으로 묶는 게 핵심입니다.
import { useState } from 'react';
function SimpleInput() {
const [text, setText] = useState('');
return (
<div>
<input
type="text"
value={text}
onChange={(e) => setText(e.target.value)}
/>
<p>입력값: {text}</p>
</div>
);
}
export default SimpleInput;흐름은 다음과 같습니다.
- 사용자가 키를 입력 → 브라우저가
change이벤트 발생 onChange핸들러가e.target.value로 새 값을 꺼내setText로 state에 반영- state가 바뀌면 컴포넌트가 다시 렌더링됨
- 새로 렌더링될 때
<input value={text}>가 갱신된 값을 화면에 표시
겉으로 보면 그냥 입력하는 대로 글자가 보이는 평범한 입력창인데, 내부적으로는 state를 거쳐 다시 화면에 그려지는 한 사이클이 매번 돌고 있는 것입니다. 이게 controlled component입니다.
왜 이렇게 번거롭게? #
“브라우저가 알아서 입력값을 기억할 텐데 굳이 state로 받아 다시 그려야 하나?“라고 생각할 수 있습니다. 맞습니다. 그냥 두면 브라우저가 알아서 값을 보관해 줍니다 (이게 uncontrolled). 그런데 controlled로 두면 얻는 게 많습니다.
- 실시간 가공 / 검증이 쉬워진다 (입력 길이 제한, 자동 대문자화, 형식 검증 등)
- 두 입력 필드가 연동 되도록 만들 수 있다 (한쪽 변경 시 다른 쪽 자동 갱신)
- state로 입력값을 다른 컴포넌트와 공유 할 수 있다 (11장에서 다룰 패턴)
- 제출 버튼 활성화 조건을 입력 상태로 즉시 표현할 수 있다 (이미 7장에서 봤죠)
대부분의 폼 시나리오에서 controlled가 더 직관적이고 강력합니다.
textarea #
textarea도 input처럼 value / onChange를 씁니다. HTML에서는 <textarea>여기 텍스트</textarea>처럼 자식으로 값을 넣었지만, 리액트에서는 value 속성으로 다룹니다.
import { useState } from 'react';
function MemoForm() {
const [memo, setMemo] = useState('');
return (
<textarea
value={memo}
onChange={(e) => setMemo(e.target.value)}
rows={5}
/>
);
}
export default MemoForm;select #
드롭다운 (<select>)도 동일한 패턴입니다. 선택된 옵션의 value가 e.target.value로 들어옵니다.
import { useState } from 'react';
function CategoryPicker() {
const [category, setCategory] = useState('frontend');
return (
<select value={category} onChange={(e) => setCategory(e.target.value)}>
<option value="frontend">프론트엔드</option>
<option value="backend">백엔드</option>
<option value="devops">데브옵스</option>
</select>
);
}
export default CategoryPicker;각 <option>의 value와 state 값이 매칭됩니다. 초기 state ('frontend')가 곧 처음에 선택된 옵션이 됩니다.
checkbox #
체크박스는 값이 문자열이 아니라 **체크 여부 (boolean)**입니다. 그래서 value 대신 **checked**를, e.target.value 대신 **e.target.checked**를 씁니다.
import { useState } from 'react';
function AgreeCheckbox() {
const [agreed, setAgreed] = useState(false);
return (
<label>
<input
type="checkbox"
checked={agreed}
onChange={(e) => setAgreed(e.target.checked)}
/>
약관에 동의합니다
</label>
);
}
export default AgreeCheckbox;radio #
라디오 버튼은 여러 개 중 하나를 선택하는 그룹입니다. 같은 name으로 묶고, checked는 state === 해당 옵션 값으로 표현합니다.
import { useState } from 'react';
function PaymentRadio() {
const [payment, setPayment] = useState('card');
return (
<div>
<label>
<input
type="radio"
name="payment"
value="card"
checked={payment === 'card'}
onChange={(e) => setPayment(e.target.value)}
/>
카드
</label>
<label style={{ marginLeft: '12px' }}>
<input
type="radio"
name="payment"
value="bank"
checked={payment === 'bank'}
onChange={(e) => setPayment(e.target.value)}
/>
계좌이체
</label>
</div>
);
}
export default PaymentRadio;여러 필드를 한 객체로 관리하기 #
폼 필드가 많아지면 useState를 여러 번 쓰는 게 번거로워집니다. 한 객체에 묶어 두는 패턴도 자주 보입니다.
import { useState } from 'react';
function SignupForm() {
const [form, setForm] = useState({
name: '',
email: '',
password: '',
agreed: false,
});
function handleChange(e) {
const { name, type, value, checked } = e.target;
setForm(prev => ({
...prev,
[name]: type === 'checkbox' ? checked : value,
}));
}
return (
<form>
<input name="name" value={form.name} onChange={handleChange} placeholder="이름" />
<input name="email" value={form.email} onChange={handleChange} placeholder="이메일" />
<input name="password" type="password" value={form.password} onChange={handleChange} placeholder="비밀번호" />
<label>
<input name="agreed" type="checkbox" checked={form.agreed} onChange={handleChange} />
약관 동의
</label>
</form>
);
}
export default SignupForm;핵심 트릭 두 가지입니다.
- 각 입력 요소에
name속성을 줘서 어떤 필드인지 구분 setForm(prev => ({ ...prev, [name]: ... }))로 객체의 일부만 갱신 (5장에서 배운 패턴)
핸들러 하나로 여러 필드를 처리할 수 있어 코드가 깔끔합니다. 다만 필드별 검증 로직 같은 게 복잡해지면 다시 useState를 분리하는 쪽이 나아질 수도 있습니다. 정해진 답은 없고 상황에 맞게 선택하시면 됩니다.
폼 제출 처리 #
6장에서 다룬 내용 복습입니다. <form>의 onSubmit을 쓰고, e.preventDefault()로 페이지 새로고침을 막는 것이 정석입니다.
function handleSubmit(e) {
e.preventDefault();
if (!form.agreed) {
alert('약관에 동의해주세요.');
return;
}
console.log('가입 정보:', form);
// 실제로는 여기서 서버에 전송
}
return (
<form onSubmit={handleSubmit}>
{/* ... */}
<button type="submit">가입</button>
</form>
);<button type="submit">을 누르거나 입력창에서 엔터를 치면 폼이 제출됩니다. 버튼의 기본 type은 submit 이므로 굳이 명시하지 않아도 폼 안에서는 제출 버튼으로 동작하지만, 헷갈리지 않게 명시하는 습관을 들이는 편이 좋습니다.
27장 (Server Actions와 폼)에서는 <form onSubmit={...}> + e.preventDefault() + fetch('/api/...')의 세 단계가 <form action={serverFn}> 한 단계로 합쳐지는 새 모델을 다루겠습니다. 본 챕터의 controlled 패턴이 그 모델에서도 그대로 살아남습니다 — value / onChange 쌍은 그대로 유지된 채로 제출만 새 방식으로 바뀝니다.
입력값 정제하기 #
controlled의 강점 중 하나는 입력값을 자유롭게 가공할 수 있다는 것입니다. set 직전에 손을 대면 됩니다.
<input
value={code}
onChange={(e) => setCode(e.target.value.toUpperCase())}
/><input
value={phone}
onChange={(e) => setPhone(e.target.value.replace(/\D/g, ''))}
/><input
value={message}
onChange={(e) => setMessage(e.target.value.slice(0, 100))}
/>화면에 보이는 값과 state 값이 항상 일치하므로, 사용자가 보는 즉시 결과가 반영됩니다.
직접 해보기 #
가입 폼을 만들어 봅니다. 여러 종류의 입력 요소를 한 화면에 모은 종합 예제입니다.
src/SignupForm.jsx:
import { useState } from 'react';
function SignupForm() {
const [form, setForm] = useState({
name: '',
email: '',
age: '',
gender: 'female',
interests: {
frontend: false,
backend: false,
design: false,
},
bio: '',
agreed: false,
});
const [submitted, setSubmitted] = useState(null);
function handleChange(e) {
const { name, type, value, checked } = e.target;
setForm(prev => ({
...prev,
[name]: type === 'checkbox' ? checked : value,
}));
}
function handleInterestChange(e) {
const { name, checked } = e.target;
setForm(prev => ({
...prev,
interests: { ...prev.interests, [name]: checked },
}));
}
function handleSubmit(e) {
e.preventDefault();
if (!form.agreed) {
alert('약관에 동의해주세요.');
return;
}
setSubmitted(form);
}
return (
<div style={{ padding: '16px', border: '1px solid #ccc', borderRadius: '8px', maxWidth: '400px' }}>
<h2>회원 가입</h2>
<form onSubmit={handleSubmit}>
<div>
<label>이름: </label>
<input name="name" value={form.name} onChange={handleChange} />
</div>
<div>
<label>이메일: </label>
<input name="email" type="email" value={form.email} onChange={handleChange} />
</div>
<div>
<label>나이: </label>
<input
name="age"
type="text"
value={form.age}
onChange={(e) => setForm(prev => ({
...prev,
age: e.target.value.replace(/\D/g, ''),
}))}
/>
</div>
<div>
<label>성별: </label>
<label>
<input type="radio" name="gender" value="female" checked={form.gender === 'female'} onChange={handleChange} />
여성
</label>
<label style={{ marginLeft: '8px' }}>
<input type="radio" name="gender" value="male" checked={form.gender === 'male'} onChange={handleChange} />
남성
</label>
</div>
<div>
<label>관심 분야: </label>
<label>
<input type="checkbox" name="frontend" checked={form.interests.frontend} onChange={handleInterestChange} />
프론트엔드
</label>
<label style={{ marginLeft: '8px' }}>
<input type="checkbox" name="backend" checked={form.interests.backend} onChange={handleInterestChange} />
백엔드
</label>
<label style={{ marginLeft: '8px' }}>
<input type="checkbox" name="design" checked={form.interests.design} onChange={handleInterestChange} />
디자인
</label>
</div>
<div>
<label>자기소개: </label>
<textarea name="bio" value={form.bio} onChange={handleChange} rows={3} />
</div>
<div>
<label>
<input type="checkbox" name="agreed" checked={form.agreed} onChange={handleChange} />
약관에 동의합니다
</label>
</div>
<button type="submit" disabled={!form.agreed} style={{ marginTop: '8px' }}>
가입
</button>
</form>
{submitted && (
<pre style={{ marginTop: '16px', background: '#f4f4f4', padding: '8px' }}>
{JSON.stringify(submitted, null, 2)}
</pre>
)}
</div>
);
}
export default SignupForm;src/App.jsx에 연결합니다.
import SignupForm from './SignupForm';
function App() {
return <SignupForm />;
}
export default App;여러 종류의 입력 요소가 모두 controlled로 동작합니다. 나이 입력은 자동으로 숫자만 받고, 약관에 동의해야만 제출 버튼이 활성화됩니다. 제출하면 입력 결과가 JSON으로 화면에 출력됩니다.
연습문제 #
- 위
SignupForm의 이메일 입력에 간단한 검증을 추가해 보세요.form.email이@를 포함하지 않으면 입력 필드 아래에 빨간색 안내 (“올바른 이메일 형식이 아닙니다”)를 표시하고, 그 동안은 제출 버튼이 비활성화되도록 만듭니다. 7장의 조건부 렌더링 패턴과 결합합니다. - 입력값 정제 연습. 전화번호 입력 필드를 추가하고, 숫자만 받되 자동으로
010-1234-5678형식으로 하이픈이 들어가도록onChange안에서 가공해 보세요. 입력 길이에 따라 다른 정규식을 적용하는 방식이 가장 단순합니다. - 9장 + 8장 결합. 새 폼 컴포넌트
TaskForm을 만들고, 제출하면 입력한 할 일이 아래 목록에 쌓이도록 작성해 보세요. 각 할 일은crypto.randomUUID()로 id를 부여하고, 항목 옆에 “완료” 체크박스를 두어 클릭하면 텍스트에 줄을 긋습니다 (textDecoration: 'line-through'). 1부에서 배운 모든 패턴이 한 컴포넌트에 모이는 종합 연습입니다.
한 줄 요약: 폼의 정석은 controlled component 다.
value/onChange한 쌍으로 입력을 state와 묶는다. 체크박스는checked/e.target.checked, 라디오는name+value+checked={state === 값}패턴. 여러 필드는 객체 하나로 묶고name속성으로 구분하는 패턴이 흔하다. 제출은<form onSubmit>+e.preventDefault(). controlled의 장점은 실시간 가공 · 검증 · 연동이다.
다음 챕터 #
본 챕터로 1부가 마무리됩니다. 컴포넌트, props, state, 이벤트, 조건부 / 리스트 / 폼까지 — 리액트의 핵심 빌딩 블록 9개를 모두 손에 익혔습니다. 지금까지 만든 컴포넌트들은 모두 자기 안에서 모든 일이 시작되고 끝났습니다. 실제 앱에서는 외부 세계와 상호작용이 필요합니다. 서버에서 데이터를 가져오고, 타이머를 설정하고, 브라우저 API를 쓰는 등의 작업입니다.
2부의 첫 챕터인 다음 10장 useEffect에서는 이런 side effect를 처리하는 표준 도구인 useEffect 훅을 배우겠습니다. “useEffect를 언제 쓰고 언제 쓰지 말아야 하는지"의 기준까지 함께 짚겠습니다.