자바스크립트로 폼을 다룰 땐 흐름이 단순했다. 사용자가 입력창에 뭘 치든 브라우저가 알아서 기억하고, 필요할 때 input.value를 읽으면 그만이었다. 리액트로 넘어오면 이 감각을 한 번 뒤집어야 한다. 값을 브라우저한테 맡기지 말고, 내 상태(state, 컴포넌트가 들고 있는 데이터)에 담아두고 입력창은 그 상태를 비추기만 하게 만든다. 처음엔 왜 이렇게 번거롭게 하나 싶은데, 폼이 조금만 복잡해지면 이 방식이 왜 표준이 됐는지 몸으로 알게 된다.
그래서 상황과 코드, 그리고 화면에서 뭐가 벌어지는지를 같이 보여주겠다. 전부 내가 실제로 헤맨 지점들이다.
제어 컴포넌트는 입력값을 상태가 쥐고 있는 방식이다. 입력창의 value를 상태에서 내려주고, 사용자가 타이핑하면 onChange로 그 상태를 갱신한다. 값이 상태에서 화면으로, 화면의 변화가 다시 상태로 돌아오니 양방향으로 묶인 셈이다.
function NameForm() {
const [name, setName] = useState("");
return (
<input
value={name}
onChange={(e) => setName(e.target.value)}
/>
);
}
타이핑할 때마다 흐름은 이렇다. 키를 누르면 onChange가 setName을 부르고, 상태가 바뀌니 컴포넌트가 다시 렌더된다. 다시 그려질 때 value={name}이 방금 바뀐 값을 반영한다. 그래서 화면에 글자가 보인다. 입력창이 스스로 값을 들고 있는 게 아니라, 매 글자마다 상태를 거쳐서 되돌아오는 것이다.
onChange를 빼먹으면 입력창이 얼어붙는다. 이게 리액트 초보가 거의 다 한 번씩 밟는 지뢰다. value만 주고 onChange를 안 달면 어떻게 되는지 보자.
<input value={name} />
// 타이핑해도 글자가 안 들어간다
// 경고: You provided a `value` prop to a form field without an `onChange` handler.
value가 상태에 고정돼 있는데 그걸 바꿀 통로(onChange)가 없으니, 무슨 키를 눌러도 화면은 계속 원래 상태값만 그린다. 사용자 입장에선 입력창이 죽은 것처럼 보인다. 나는 이걸 처음에 "키보드가 고장났나" 싶어 한참을 딴 데서 찾았다. 값을 읽기만 하고 쓰지는 않게 만들었으니 당연한 결과였다. 정말로 읽기 전용이 목적이면 readOnly를 붙여 의도를 밝히면 경고도 사라진다.
입력창이 여러 개면 name으로 한 함수에서 처리한다. 필드마다 핸들러를 따로 만들면 금방 지저분해진다. 상태를 객체 하나로 두고, 각 입력창에 name을 달아 그 이름으로 갱신한다.
function SignupForm() {
const [form, setForm] = useState({ id: "", pw: "" });
const onChange = (e) => {
const { name, value } = e.target;
setForm((prev) => ({ ...prev, [name]: value }));
};
return (
<>
<input name="id" value={form.id} onChange={onChange} />
<input name="pw" value={form.pw} onChange={onChange} />
</>
);
}
어느 입력창에서 이벤트가 나든 e.target.name이 어떤 필드인지 알려주고, [name]: value로 그 키만 바꾼다. ...prev로 나머지 필드를 그대로 복사하는 게 중요하다. 이걸 빼고 setForm({ [name]: value })라고만 쓰면, 아이디를 칠 때 비밀번호가 통째로 날아간다. 상태를 통으로 갈아끼우지 말고 이전 값 위에 덮어써야 한다.
체크박스는 value가 아니라 checked를 본다. 텍스트 입력만 하다 체크박스를 만나면 헷갈린다. 체크박스의 상태는 문자열이 아니라 켜짐/꺼짐이라, checked에 불리언(true/false)을 물려준다.
function AgreeBox() {
const [agree, setAgree] = useState(false);
return (
<input
type="checkbox"
checked={agree}
onChange={(e) => setAgree(e.target.checked)}
/>
);
}
여기서 e.target.value를 읽으면 "on"이라는 엉뚱한 문자열이 잡힌다. 체크박스가 실제로 알려주는 건 e.target.checked다. 셀렉트(select) 박스는 다시 텍스트처럼 value와 onChange로 다루면 되는데, 옵션을 감싸는 <select> 쪽에 value를 걸어야지 각 <option>에 걸면 안 된다.
제출은 onSubmit에서 기본 동작을 막고 시작한다. 폼을 <form>으로 감싸고 제출을 처리할 땐, 브라우저가 페이지를 새로고침하려는 기본 동작부터 끊어야 한다.
function LoginForm() {
const [id, setId] = useState("");
const onSubmit = (e) => {
e.preventDefault();
console.log(id);
};
return (
<form onSubmit={onSubmit}>
<input value={id} onChange={(e) => setId(e.target.value)} />
<button>로그인</button>
</form>
);
}
e.preventDefault()를 빼먹으면 버튼을 누르는 순간 페이지가 새로고침되면서 방금 입력한 값도, 콘솔 로그도 눈 깜짝할 새 사라진다. 나는 이걸 모르고 "왜 로그가 찍히다 마나" 하며 한참 봤는데, 화면이 리로드되니 로그가 지워졌던 것이다. 폼 제출을 리액트가 다루게 하려면 브라우저의 기본 제출부터 막아두는 게 시작이다.
value에 undefined가 새면 제어와 비제어를 오간다는 경고가 뜬다. 상태 초깃값을 빼먹거나 form.id가 undefined인 채로 value에 들어가면 이런 경고를 본다.
// Warning: A component is changing an uncontrolled input to be controlled.
처음엔 값이 undefined라 리액트가 이 입력창을 비제어(브라우저가 값을 쥔 상태)로 보다가, 나중에 문자열이 들어오면서 갑자기 제어로 바뀌니 헷갈린다고 알려주는 것이다. 해법은 단순하다. 초깃값을 항상 빈 문자열 ""로 둬서 처음부터 끝까지 제어 상태를 유지하면 된다. useState()가 아니라 useState("")로 시작하는 습관 하나로 이 경고는 안 만난다.
결국 폼은 상태 하나를 화면과 동기화하는 일이다. 리액트 폼이 번거로워 보이는 건 값을 상태에 담고 onChange로 되돌리는 왕복 때문인데, 이 왕복 덕에 지금 입력값이 어디에 있는지가 늘 명확하다. 유효성 검사, 버튼 비활성화, 다른 필드에 따라 바뀌는 입력창까지 전부 상태 하나만 보면 된다. 브라우저가 몰래 쥐고 있던 값을 내 손으로 가져오는 대신, 폼 전체가 예측 가능해지는 거래다. 처음의 번거로움만 넘기면 복잡한 폼일수록 이 방식이 편하다.