폼 제출이나 링크 이동의 기본 동작을 막을 때 씁니다. 폼에서 페이지가 새로고침되는 것을 막는 용도로 가장 많이 사용합니다.
이 파트에서 다루는 내용
이벤트 핸들러는 함수를 넘깁니다
onClick에는 실행 결과가 아니라 함수 자체를 넘겨야 합니다. 괄호를 붙이면 렌더링 시점에 바로 실행됩니다.
자식에서 발생한 이벤트가 부모로 올라갑니다. 목록 행 클릭과 행 안의 버튼 클릭이 겹칠 때 전파를 멈춰야 합니다.
// 잘못: 렌더링될 때 즉시 실행됩니다
<button onClick={handleDelete()}>삭제</button>
// 올바름: 함수를 넘깁니다
<button onClick={handleDelete}>삭제</button>
// 인자가 필요하면 화살표 함수로 감쌉니다
<button onClick={() => handleDelete(order.id)}>삭제</button>첫 번째 코드는 클릭하지 않았는데도 삭제가 실행되고, 그 결과로 다시 렌더링되면서 무한 반복에 빠질 수 있습니다.
제어 컴포넌트가 기본입니다
입력값을 state로 관리하고 화면에 그 값을 표시하는 방식을 제어 컴포넌트라고 합니다. 입력값이 항상 state와 일치하므로 예측이 쉽습니다.
반대로 DOM이 값을 갖게 두고 필요할 때만 읽는 방식이 비제어 컴포넌트입니다. 입력이 많은 화면에서 렌더링 부담이 적습니다.
- 값이 state에 있어 언제든 확인·조작 가능
- 입력 중 실시간 검증과 형식 변환이 쉬움
- 입력할 때마다 렌더링이 발생
- DOM이 값을 보관하고 제출 시점에 읽음
- 입력이 아주 많은 폼에서 유리
- 파일 입력은 비제어로만 다룹니다
value에 undefined를 넣었다가 나중에 값을 넣으면 비제어에서 제어로 바뀌었다는 경고가 나옵니다. 초기값을 빈 문자열로 둡니다.
function OrderForm({ onSubmit }) {
const [form, setForm] = useState({ receiver: "", phone: "", memo: "" });
const handleChange = (event) => {
const { name, value } = event.target;
setForm((prev) => ({ ...prev, [name]: value }));
};
return (
<form onSubmit={handleSubmit}>
<input name="receiver" value={form.receiver} onChange={handleChange} />
<input name="phone" value={form.phone} onChange={handleChange} />
<textarea name="memo" value={form.memo} onChange={handleChange} />
</form>
);
}input의 name 속성과 state의 키 이름을 맞추면 핸들러 하나로 여러 입력을 처리할 수 있습니다.
폼 제출에서 놓치기 쉬운 것들
폼은 단순해 보이지만 실무에서 문제가 자주 생깁니다. 특히 중복 제출과 오류 처리를 빠뜨리는 경우가 많습니다.
버튼을 빠르게 두 번 누르면 요청이 두 번 갑니다. 주문이나 결제에서는 실제 피해로 이어집니다.
어떤 필드가 왜 틀렸는지 해당 입력 옆에 표시합니다. 상단에 뭉뚱그려 보여 주면 사용자가 찾지 못합니다.
입력 중 즉시 검증하면 아직 다 치지 않았는데 오류가 뜹니다. 처음에는 포커스가 빠질 때 검증하고, 한 번 오류가 난 필드만 입력 중 재검증하는 방식이 무난합니다.
화면 검증은 사용성을 위한 것이지 보안 장치가 아닙니다. 서버에서 반드시 다시 검증합니다. Spring Boot 코스 Part 5와 이어집니다.
function OrderForm() {
const [form, setForm] = useState({ receiver: "", phone: "" });
const [errors, setErrors] = useState({});
const [isSubmitting, setIsSubmitting] = useState(false);
const handleSubmit = async (event) => {
event.preventDefault(); // 새로고침 방지
if (isSubmitting) return; // 중복 제출 방지
const nextErrors = validate(form);
setErrors(nextErrors);
if (Object.keys(nextErrors).length > 0) return;
setIsSubmitting(true);
try {
await createOrder(form);
// 성공 처리
} catch (error) {
setErrors({ form: "주문 처리에 실패했습니다. 잠시 후 다시 시도해 주세요." });
} finally {
setIsSubmitting(false); // 실패해도 반드시 해제합니다
}
};
return (
<form onSubmit={handleSubmit}>
{/* 입력 필드들 */}
<button type="submit" disabled={isSubmitting}>
{isSubmitting ? "처리 중..." : "주문하기"}
</button>
</form>
);
}finally에서 상태를 해제하는 부분이 중요합니다. 실패했을 때 버튼이 계속 비활성 상태로 남으면 사용자가 다시 시도할 수 없습니다.
폼이 복잡해지면 라이브러리를 검토합니다
필드가 많아지고 검증 규칙이 늘어나면 직접 관리하는 코드가 빠르게 복잡해집니다. 이 시점에는 폼 전용 라이브러리를 검토할 만합니다.
다만 도입 자체가 목적이 되면 안 됩니다. 입력 세 개짜리 폼에 라이브러리를 붙이면 오히려 코드가 늘어납니다.
- 필드가 열 개를 넘고 필드 간 의존 규칙이 있다면 검토합니다.
- 같은 형태의 폼이 여러 화면에 반복되면 공통 처리 이점이 큽니다.
- 간단한 폼은 useState로 충분합니다.
버전별 참고
본문은 React 19 기준입니다. 18과 달라지는 부분은 아래에 정리합니다.
- React 19에서는 form의 action에 함수를 직접 넘기고, 제출 상태와 낙관적 업데이트를 다루는 훅이 추가됐습니다. 위 예제의 수동 처리 상당 부분을 대체할 수 있습니다.
- 다만 React 18 프로젝트에서는 사용할 수 없습니다. 이 파트의 기본 패턴은 두 버전 모두에서 동작합니다.
- 새 프로젝트를 19로 시작한다면 공식 문서의 폼 관련 훅을 함께 확인하는 편이 좋습니다.