폼은 상태 관리의 종합 시험장이다. 입력이 여러 개고, 그 값들이 실시간으로 바뀌고, 틀린 값은 걸러내야 하고, 마지막엔 한꺼번에 모아 서버로 보낸다. 앞에서 익힌 상태 다루는 감각이 폼에서 전부 시험대에 오른다. 리액트에서 폼을 처음 만들면 다들 비슷한 데서 막힌다. 입력창에 분명히 타이핑을 하는데 글자가 안 찍히는 것이다. 버그 같지만 버그가 아니다. 리액트가 폼을 다루는 방식을 몰라서 그렇다.


제어 컴포넌트. 입력값의 진실을 state가 쥔다. 순수 HTML에서 input은 자기가 알아서 타이핑을 기억한다. 브라우저가 입력값을 들고 있는 것이다. 그런데 리액트에서는 상태가 화면의 유일한 진실이길 바란다. 그래서 입력값도 state가 쥐게 만든다. 이렇게 값을 state에 묶고 onChange로 갱신하는 input을 제어 컴포넌트(controlled component, 값을 상태가 통제하는 입력)라 부른다.


function NameField() {
const [name, setName] = useState("");
return (
<input
value={name}
onChange={(e) => setName(e.target.value)}
/>
);
}


흐름은 이렇다. 사용자가 키를 누르면 onChangee.target.value로 새 값을 받아 setName을 부른다. 상태가 바뀌니 리렌더가 일어나고, inputvalue가 새 name으로 다시 그려진다. 화면에 글자가 찍히는 건 이 한 바퀴가 돈 결과다. 아까 글자가 안 찍힌다던 그 버그는, value={name}만 걸고 onChange를 안 단 경우다. valuestate에 고정됐는데 그걸 바꿔줄 통로가 없으니, 타이핑을 해도 name은 계속 빈 문자열이라 화면도 빈 채로 멈춘다. 제어 컴포넌트는 valueonChange가 한 쌍으로 붙어 다녀야 한다.


필드가 여러 개면 객체 하나로 묶는다. 입력이 대여섯 개인 폼에서 useState를 그 수만큼 선언하면 금세 지저분해진다. 관련된 필드는 객체 하나에 담고, 어느 필드가 바뀌었는지는 inputname 속성으로 구분하는 방식이 흔하다.


const [form, setForm] = useState({ email: "", password: "" });

function handleChange(e) {
const { name, value } = e.target;
setForm((prev) => ({ ...prev, [name]: value }));
}


handleChange 하나로 모든 입력을 받는다. e.target.nameemail인지 password인지를 보고, 대괄호 표기 [name]로 그 필드만 골라 바꾼다. 여기서 반드시 지킬 게 있다. 상태는 늘 새 객체로 교체해야 한다. prev.email = value처럼 기존 객체를 직접 고치면, 참조가 그대로라 리액트가 변화를 못 알아채고 리렌더를 건너뛴다. 그래서 { ...prev }로 기존 값을 펼친 새 객체를 만들고 바뀐 필드만 덮어쓴다. input 쪽은 name="email", value={form.email}, onChange={handleChange}를 걸면 된다.


검증. 언제 틀렸다고 알릴지가 관건이다. 검증 자체는 어렵지 않다. 이메일에 골뱅이가 있나, 비밀번호가 여덟 자 이상인가 같은 조건을 확인하면 된다. 진짜 고민은 타이밍이다. 사용자가 이메일을 한 글자 치자마자 형식이 틀렸다고 빨갛게 띄우면, 다 치기도 전에 잔소리부터 듣는 꼴이라 짜증난다. 그렇다고 제출할 때까지 아무 말이 없으면, 다 채우고 버튼을 누른 뒤에야 틀린 걸 알게 된다. 보통은 그 입력창을 벗어날 때(blur, 포커스가 빠지는 순간)나 제출할 때 검증하는 쪽으로 절충한다. 에러 메시지도 상태로 들고 있어야 화면에 그릴 수 있다.


const [errors, setErrors] = useState({});

function validate(values) {
const next = {};
if (!values.email.includes("@")) {
next.email = "이메일 형식이 아닙니다";
}
if (values.password.length < 8) {
next.password = "비밀번호는 8자 이상이어야 합니다";
}
return next;
}


validate는 값 객체를 받아 문제가 있는 필드만 담은 에러 객체를 돌려준다. 통과하면 빈 객체다. 이걸 errors 상태에 넣어두고, 화면에서는 errors.email이 있으면 그 메시지를 입력창 밑에 빨갛게 그리면 된다. 에러도 결국 하나의 상태이고, 데이터 상태와 똑같은 방식으로 다룬다는 게 요점이다.


제출. 기본 동작을 막고 마지막에 한 번 더 검증한다. 폼을 form 태그로 감싸고 onSubmit을 달면, 버튼을 누르거나 엔터를 칠 때 제출이 걸린다. 이때 가장 먼저 할 일은 브라우저의 기본 제출 동작을 막는 것이다. 안 막으면 페이지가 통째로 새로고침되면서 지금까지 쌓아둔 상태가 다 날아간다.


function handleSubmit(e) {
e.preventDefault();
const next = validate(form);
setErrors(next);
if (Object.keys(next).length > 0) {
return;
}
// 여기서 서버로 form을 보낸다
}


e.preventDefault()로 새로고침을 막고, 제출 직전에 전체 값을 다시 한 번 검증한다. 입력 중간중간 검사했더라도 마지막에 통째로 다시 보는 이유는, 사용자가 아무것도 안 건드리고 바로 제출 버튼을 누를 수도 있기 때문이다. 에러가 하나라도 있으면 return으로 막고, 다 통과했을 때만 서버로 보낸다. 검증을 화면 렌더가 아니라 제출 시점에서 한 번 더 잠그는 이 이중 확인이, 폼이 조용히 잘못된 값을 넘기는 사고를 막는다.


제어 컴포넌트의 대가, 그리고 비제어라는 선택. 제어 컴포넌트는 값을 완전히 손에 쥐는 대신 대가가 있다. 키를 누를 때마다 setState가 돌고 컴포넌트가 리렌더된다. 입력창 몇 개짜리 폼에선 이 비용이 티도 안 나니 신경 쓸 것 없다. 문제는 필드가 수십 개거나 리렌더가 유난히 무거운 화면이다. 그럴 때 대안이 비제어 컴포넌트(uncontrolled component, 값을 브라우저에 맡기는 입력)다. 값을 매 순간 state에 반영하지 않고 브라우저가 알아서 들고 있게 두었다가, 제출하는 순간 ref로 값을 한 번에 읽어오는 방식이다.


function LoginForm() {
const emailRef = useRef(null);
function handleSubmit(e) {
e.preventDefault();
console.log(emailRef.current.value);
}
return (
<form onSubmit={handleSubmit}>
<input ref={emailRef} defaultValue="" />
</form>
);
}


타이핑하는 동안엔 리렌더가 한 번도 안 일어나고, 제출할 때 emailRef.current.value로 값을 긁어온다. value 대신 defaultValue를 쓰는 게 비제어라는 표시다. 대신 실시간 검증이나 입력값에 따라 화면을 즉각 바꾸는 건 어려워진다. 실무에서는 이 둘을 저울질하는 것도 번거로워서 React Hook Form 같은 폼 라이브러리를 많이 쓴다. 내부적으로 비제어 방식을 써서 리렌더를 줄이면서, 검증과 에러 관리는 편하게 해주는 도구다. 다만 그 편리함도 지금 본 제어 대 비제어, valueonChange의 관계를 손으로 한 번 겪어본 뒤라야 제대로 와닿는다. 폼은 결국 상태를 어디에 두느냐의 문제이고, 그 감각이 서면 어떤 폼 도구를 만나도 헤매지 않는다.