폼 다루기 기초
input·textarea·select를 상태와 연결하는 제어 컴포넌트를 구현하고 여러 입력을 하나의 핸들러로 관리합니다.
앞 글에서는 리스트와 key로 여러 데이터를 효율적으로 렌더링하는 방법을 배웠습니다. 이제 사용자에게서 데이터를 입력받는 중요한 UI 요소인 폼(form)을 React에서 어떻게 다루는지 알아볼 차례입니다. 로그인, 회원가입, 게시물 작성, 검색처럼 대부분의 웹 애플리케이션은 사용자에게서 다양한 형태의 입력을 받습니다. React는 폼 요소를 관리하고, 사용자의 입력을 실시간으로 처리하며, 그 값을 제출하는 과정을 체계적으로 다룰 수 있게 돕습니다. 이 글에서는 React에서 폼을 다루는 핵심 개념인 제어 컴포넌트(controlled component)를 중심으로 살펴봅니다.
제어 컴포넌트란?
먼저 폼의 값을 React의 state가 가지는지, DOM이 가지는지에 따라 입력의 흐름이 어떻게 달라지는지 살펴봅니다.
제어 폼의 입력 루프 — 상태 추적
김코딩 입력의 DOM 현재 값과 React snapshot/요청/다음 value가 루프에서 달라진다. 비제어 DOM과 state 소유권을 구별한다.
CONTROLLED INPUT LOOP
입력값은 state를 거쳐 다음 렌더의 value로 돌아옵니다
NameForm은 김코딩이라는 입력을 name에 저장하고 입력 칸과 표시 문장에 같은 값을 사용합니다. 제출은 현재 name을 alert로 보여주며 본문 예제는 서버 요청을 보내지 않습니다.
NameForm · 값의 한 경로
onChange → setName(target.value)
name: "김코딩"
value={name}
표시: 입력 중인 이름: 김코딩state 초기값은 빈 문자열입니다. 제어 입력은 값과 onChange를 연결하고, 제출 시 preventDefault로 기본 페이지 이동을 막습니다.
폼 요소마다 읽을 prop
input / textarea / select: value
checkbox: checkedtextarea의 자식 텍스트나 option의 selected 대신 부모의 value를 사용합니다. 체크박스에서는 target.checked를 읽어 불리언 상태를 연결합니다.
여러 입력 · name으로 식별
const {name, value} = event.target;
다음 객체: {...formValues,
[name]: value}username·email·password 중 바뀐 필드만 새 객체에서 교체하고 나머지를 보존합니다.
비제어 · DOM에서 읽기
파일 입력은 보통 ref.current.files로 선택 결과를 읽습니다. NameForm의 value 제어와 구분하고, 선택 전 파일 접근·실제 업로드는 별도로 구현합니다.
이 예의 조건
- 원도표의 전체 구조와 설명은 위 관계도와 표에서 유지합니다. 아래 인쇄 표에도 원도표의 관계·조건·라벨·설명·주석을 보존합니다.
- 두 관찰 칸의 위치는 고정입니다. 관찰 상태는 해당 순간의 값이며, 동작·이유는 그 조건에서의 판정과 변화 원인입니다. 독립 비교와 실패 분기는 별도 조건으로 읽고 모든 분기를 한 요청의 필수 실행 순서로 이어 읽지 않습니다.
관계도의 보충 설명
| 구분 | 내용 |
|---|---|
| 원도표 설명 | NameForm은 김코딩이라는 입력을 name에 저장하고 입력 칸과 표시 문장에 같은 값을 사용합니다. 제출은 현재 name을 alert로 보여 주며 본문 예제는 서버 요청을 보내지 않습니다. |
| 전체 관계·조건 설명 | 입력 요소의 현재 DOM 값→handleChange의 target.value 읽기→setName으로 다음 상태 요청→다음 렌더에서 value 전달→입력 요소의 노드 4개·간선 4개 순환이다. setter가 현재 함수의 상태 변수를 즉시 변경한다고 표현하지 않는다. 화면 값과 상태를 같은 기준으로 맞춘다. |
| 전체 관계·조건 설명 | 입력 요소의 현재 DOM 값→handleChange의 target.value 읽기→setName으로 다음 상태 요청→다음 렌더에서 value 전달→입력 요소의 노드 4개·간선 4개 순환이다. setter가 현재 함수의 상태 변수를 즉시 변경한다고 표현하지 않는다. 화면 값과 상태를 같은 기준으로 맞춘다. 모바일은 같은 노드와 방향을 다른 배치로 보여 줍니다. |
| 장면 | 현재 관찰값 | 설명 | 변화 이유 |
|---|---|---|---|
| 사용자 입력 | DOM: 김코딩 / state: 현재 name | DOM 값은 김코딩이고 React name은 현재 snapshot이다 | 입력 사건을 받은 순간 두 관찰은 다를 수 있다 |
| setName | 요청: 김코딩 / 현재 함수: 이전 snapshot 유지 | target.value로 다음 name을 요청한다 | handler의 변수를 즉시 교체하는 동작으로 해석하지 않는다 |
| value 반영 | state: 김코딩 / input value: 김코딩 | 다음 렌더가 김코딩을 input value로 전달한다 | 제어 입력의 값 소유자는 React state다 |
제어 폼의 입력 루프 — 상태 추적
김코딩 입력의 DOM 현재 값과 React snapshot/요청/다음 value가 루프에서 달라진다. 비제어 DOM과 state 소유권을 구별한다.
CONTROLLED INPUT LOOP
입력값은 state를 거쳐 다음 렌더의 value로 돌아옵니다
NameForm은 김코딩이라는 입력을 name에 저장하고 입력 칸과 표시 문장에 같은 값을 사용합니다. 제출은 현재 name을 alert로 보여주며 본문 예제는 서버 요청을 보내지 않습니다.
NameForm · 값의 한 경로
onChange → setName(target.value)
name: "김코딩"
value={name}
표시: 입력 중인 이름: 김코딩state 초기값은 빈 문자열입니다. 제어 입력은 값과 onChange를 연결하고, 제출 시 preventDefault로 기본 페이지 이동을 막습니다.
폼 요소마다 읽을 prop
input / textarea / select: value
checkbox: checkedtextarea의 자식 텍스트나 option의 selected 대신 부모의 value를 사용합니다. 체크박스에서는 target.checked를 읽어 불리언 상태를 연결합니다.
여러 입력 · name으로 식별
const {name, value} = event.target;
다음 객체: {...formValues,
[name]: value}username·email·password 중 바뀐 필드만 새 객체에서 교체하고 나머지를 보존합니다.
비제어 · DOM에서 읽기
파일 입력은 보통 ref.current.files로 선택 결과를 읽습니다. NameForm의 value 제어와 구분하고, 선택 전 파일 접근·실제 업로드는 별도로 구현합니다.
이 예의 조건
- 원도표의 전체 구조와 설명은 위 관계도와 표에서 유지합니다. 아래 인쇄 표에도 원도표의 관계·조건·라벨·설명·주석을 보존합니다.
- 두 관찰 칸의 위치는 고정입니다. 관찰 상태는 해당 순간의 값이며, 동작·이유는 그 조건에서의 판정과 변화 원인입니다. 독립 비교와 실패 분기는 별도 조건으로 읽고 모든 분기를 한 요청의 필수 실행 순서로 이어 읽지 않습니다.
관계도의 보충 설명
| 구분 | 내용 |
|---|---|
| 원도표 설명 | NameForm은 김코딩이라는 입력을 name에 저장하고 입력 칸과 표시 문장에 같은 값을 사용합니다. 제출은 현재 name을 alert로 보여 주며 본문 예제는 서버 요청을 보내지 않습니다. |
| 전체 관계·조건 설명 | 입력 요소의 현재 DOM 값→handleChange의 target.value 읽기→setName으로 다음 상태 요청→다음 렌더에서 value 전달→입력 요소의 노드 4개·간선 4개 순환이다. setter가 현재 함수의 상태 변수를 즉시 변경한다고 표현하지 않는다. 화면 값과 상태를 같은 기준으로 맞춘다. |
| 전체 관계·조건 설명 | 입력 요소의 현재 DOM 값→handleChange의 target.value 읽기→setName으로 다음 상태 요청→다음 렌더에서 value 전달→입력 요소의 노드 4개·간선 4개 순환이다. setter가 현재 함수의 상태 변수를 즉시 변경한다고 표현하지 않는다. 화면 값과 상태를 같은 기준으로 맞춘다. 모바일은 같은 노드와 방향을 다른 배치로 보여 줍니다. |
| 장면 | 현재 관찰값 | 설명 | 변화 이유 |
|---|---|---|---|
| 사용자 입력 | DOM: 김코딩 / state: 현재 name | DOM 값은 김코딩이고 React name은 현재 snapshot이다 | 입력 사건을 받은 순간 두 관찰은 다를 수 있다 |
| setName | 요청: 김코딩 / 현재 함수: 이전 snapshot 유지 | target.value로 다음 name을 요청한다 | handler의 변수를 즉시 교체하는 동작으로 해석하지 않는다 |
| value 반영 | state: 김코딩 / input value: 김코딩 | 다음 렌더가 김코딩을 input value로 전달한다 | 제어 입력의 값 소유자는 React state다 |
위 그림은 이 글의 NameForm에서 입력값이 state를 거쳐 다음 렌더의 value로 돌아오는 순환을 보여 줍니다. 사용자가 “김코딩”을 입력하면 handleChange가 target.value로 입력 요소의 현재 값을 읽고, setName으로 다음 상태를 요청하며, 다음 렌더에서 그 값을 value로 입력 요소에 넘깁니다. 그래서 name이 “김코딩”이 되면 입력칸과 “입력 중인 이름: 김코딩”이라는 표시 문장이 같은 값을 씁니다. 그림의 설명처럼 setter가 현재 함수 안의 상태 변수를 즉시 바꾸는 것은 아니며, name의 초기값은 빈 문자열이고, 제출할 때는 preventDefault()로 기본 페이지 이동을 막습니다. 이 예제는 현재 name을 알림창으로 보여 줄 뿐 서버에 요청을 보내지 않습니다.
그림의 나머지 칸은 이 글에서 다룰 폼 요소별 규칙을 정리합니다. input, textarea, select는 value를, 체크박스는 checked를 state와 연결하며, textarea의 자식 텍스트나 option의 selected 대신 부모 요소의 value를 씁니다. 체크박스는 이 글의 예제에는 없지만, target.checked를 읽어 불리언 상태와 연결합니다. 여러 입력은 name으로 구별해 {...formValues, [name]: value}처럼 바뀐 필드만 새 객체에서 교체하고, 파일 입력 같은 비제어 요소는 ref.current.files처럼 DOM에서 값을 읽습니다.
HTML의 <input>, <textarea>, <select> 같은 폼 요소는 사용자의 입력으로 자기만의 상태를 가집니다. 예를 들어 input 필드에 글자를 입력하면 그 필드가 글자 값을 스스로 관리합니다. 이렇게 DOM이 값을 관리하는 폼 요소를 비제어 컴포넌트(uncontrolled component)라고 합니다. React에서는 폼 요소의 상태를 컴포넌트의 state로 관리하는 방식을 더 선호합니다. 사용자가 폼 요소에 값을 입력하면 그 값이 컴포넌트의 state에 저장되고, 폼 요소는 이 state에 따라 제어됩니다. 이렇게 React의 state가 값을 제어하는 폼 요소를 제어 컴포넌트라고 부릅니다.
제어 컴포넌트에는 여러 장점이 있습니다. 폼 요소의 현재 값이 언제나 컴포넌트의 state에 있으므로 값의 출처가 하나(single source of truth)가 되어, 데이터의 흐름을 예측하기 쉽고 디버깅하기 편합니다. state가 입력할 때마다 갱신되므로 사용자가 입력하는 즉시 유효성을 검사하고 오류 메시지를 보여 줄 수 있습니다. 코드에서 폼 요소의 값을 바꾸거나 초기화하기도 쉽고, 입력 형식 맞추기나 자동 완성 같은 복잡한 로직도 state를 바탕으로 적용할 수 있습니다.
input 태그 다루기: 텍스트 입력
가장 기본적인 폼 요소인 <input type="text">를 제어 컴포넌트로 다뤄 봅니다. src/components 폴더 안에 NameForm.jsx 파일을 만듭니다.
import { useState } from 'react';
function NameForm() {
// (1) input의 현재 값을 저장할 state 선언
const [name, setName] = useState('');
// (2) input 값이 변경될 때 호출될 이벤트 핸들러
const handleChange = (event) => {
// event.target.value: input 필드의 현재 값
setName(event.target.value); // state 업데이트
console.log('현재 입력 값:', event.target.value);
};
// (3) 폼 제출 시 호출될 이벤트 핸들러
const handleSubmit = (event) => {
alert('제출된 이름: ' + name); // state에 저장된 이름 출력
event.preventDefault(); // (4) 폼의 기본 동작(페이지 새로고침) 방지
};
return (
<div style={{ border: '1px solid #009688', padding: '20px', margin: '20px', borderRadius: '8px' }}>
<h2>이름 입력 폼</h2>
<form onSubmit={handleSubmit}>
<label>
이름:
<input
type="text"
value={name}
onChange={handleChange}
style={{ marginLeft: '10px', padding: '8px', fontSize: '16px', borderRadius: '4px', border: '1px solid #ccc' }}
/>
</label>
<button type="submit" style={{ marginLeft: '10px', padding: '8px 15px', fontSize: '16px', backgroundColor: '#007bff', color: 'white', border: 'none', borderRadius: '4px', cursor: 'pointer' }}>
제출
</button>
</form>
<p style={{ marginTop: '15px' }}>
입력 중인 이름: <span style={{ fontWeight: 'bold', color: '#007bff' }}>{name}</span>
</p>
</div>
);
}
export default NameForm;name state는 입력 필드의 현재 글자 값을 저장하며 초기값은 빈 문자열('')입니다. handleChange는 <input>의 onChange 이벤트에 연결된 함수로, event.target.value로 사용자가 입력한 값을 가져와 setName으로 name을 갱신합니다. 이 과정이 입력할 때마다 입력 필드의 값과 state를 맞추는 핵심입니다. handleSubmit은 <form>의 onSubmit 이벤트에 연결되어 폼이 제출될 때 호출되며, event.preventDefault()가 폼을 제출할 때 페이지가 새로고침되는 브라우저의 기본 동작을 막습니다. 그리고 <input>의 value 속성을 name state에 연결했기 때문에, 입력 필드의 값은 언제나 name이 정합니다. 이것이 제어 컴포넌트의 핵심입니다.
이제 App.jsx에서 NameForm을 씁니다.
import './App.css';
import NameForm from './components/NameForm'; // NameForm 컴포넌트 불러오기
function App() {
return (
<div className="App">
<h1>React 폼 다루기</h1>
<NameForm />
</div>
);
}
export default App;브라우저의 입력 필드에 글자를 입력하면 아래의 “입력 중인 이름:” 뒤의 글자가 실시간으로 바뀌고, Console에는 입력할 때마다 “현재 입력 값:”과 그때의 값이 찍힙니다. 제출 버튼을 누르면 “제출된 이름: ”과 입력한 이름이 담긴 알림창이 뜨고, 페이지는 새로고침되지 않습니다.
textarea 태그 다루기
<textarea>는 여러 줄의 글을 입력받을 때 쓰며, input과 거의 같은 방식으로 다룹니다. HTML에서는 태그 사이에 기본 글을 적지만, React에서는 <input>처럼 value 속성에 state를 연결하고 onChange로 갱신합니다. 아래 코드의 초기값은 자리표시자(placeholder)가 아니라 실제 입력값이므로, 사용자가 글을 쓰려면 이 문장을 지우고 시작해야 합니다. 이 글의 나머지 예제인 EssayForm, FlavorForm, MultiInputForm도 코드 블록 제목의 경로에 저장한 뒤, NameForm처럼 App.jsx에서 불러와 <NameForm /> 아래에 렌더링해 확인합니다.
import { useState } from 'react';
function EssayForm() {
const [essay, setEssay] = useState('이곳에 에세이를 작성하세요...'); // 초기값 설정
const handleChange = (event) => {
setEssay(event.target.value);
};
const handleSubmit = (event) => {
alert('제출된 에세이:\n' + essay);
event.preventDefault();
};
return (
<div style={{ border: '1px solid #fd7e14', padding: '20px', margin: '20px', borderRadius: '8px' }}>
<h2>에세이 작성 폼</h2>
<form onSubmit={handleSubmit}>
<label>
에세이:
<textarea
value={essay}
onChange={handleChange}
rows="5"
cols="40"
style={{ display: 'block', marginTop: '10px', padding: '8px', fontSize: '16px', borderRadius: '4px', border: '1px solid #ccc', width: '90%' }}
/>
</label>
<button type="submit" style={{ marginTop: '15px', padding: '8px 15px', fontSize: '16px', backgroundColor: '#fd7e14', color: 'white', border: 'none', borderRadius: '4px', cursor: 'pointer' }}>
에세이 제출
</button>
</form>
<p style={{ marginTop: '15px' }}>
현재 에세이:<br />
<span style={{ whiteSpace: 'pre-wrap', color: '#dc3545' }}>{essay}</span>
</p>
</div>
);
}
export default EssayForm;HTML에서는 <textarea>의 자식 텍스트로 초기값을 넣었지만, React에서는 value 속성으로 초기화합니다. 이 예제의 초기값 “이곳에 에세이를 작성하세요...”는 안내 문구(placeholder)가 아니라 실제 값이므로, 사용자가 지우고 써야 합니다. 아래 표시에 쓴 whiteSpace: 'pre-wrap' 스타일은 HTML의 <pre> 태그처럼 공백과 줄바꿈을 그대로 유지해 줍니다.
select 태그 다루기
HTML의 <select> 태그는 드롭다운 목록을 만들며, React에서는 이것도 제어 컴포넌트로 다룰 수 있습니다. HTML에서는 선택된 항목에 selected 속성을 주었지만, React에서는 <select> 태그의 value 속성에 선택된 값을 지정합니다.
import { useState } from 'react';
function FlavorForm() {
// state의 초기값으로 기본 선택될 option의 value를 지정
const [selectedFlavor, setSelectedFlavor] = useState('grapefruit');
const handleChange = (event) => {
setSelectedFlavor(event.target.value);
};
const handleSubmit = (event) => {
alert('선택한 과일: ' + selectedFlavor);
event.preventDefault();
};
return (
<div style={{ border: '1px solid #20c997', padding: '20px', margin: '20px', borderRadius: '8px' }}>
<h2>좋아하는 과일 선택</h2>
<form onSubmit={handleSubmit}>
<label>
좋아하는 과일을 선택하세요:
<select value={selectedFlavor} onChange={handleChange}
style={{ marginLeft: '10px', padding: '8px', fontSize: '16px', borderRadius: '4px', border: '1px solid #ccc' }}>
<option value="grapefruit">자몽</option>
<option value="lime">라임</option>
<option value="coconut">코코넛</option>
<option value="mango">망고</option>
</select>
</label>
<button type="submit" style={{ marginLeft: '10px', padding: '8px 15px', fontSize: '16px', backgroundColor: '#20c997', color: 'white', border: 'none', borderRadius: '4px', cursor: 'pointer' }}>
제출
</button>
</form>
<p style={{ marginTop: '15px' }}>
선택된 과일: <span style={{ fontWeight: 'bold', color: '#20c997' }}>{selectedFlavor}</span>
</p>
</div>
);
}
export default FlavorForm;<select> 태그 자체의 value 속성에 selectedFlavor state를 연결했고, option 태그에는 value 속성만 주고 selected 속성은 쓰지 않았습니다. 처음에는 state의 초기값 'grapefruit'에 맞춰 “자몽”이 선택되어 있고, 아래에는 “선택된 과일: grapefruit”이 보입니다. 표시되는 값은 option의 글자가 아니라 value라는 점도 확인해 보세요.
여러 입력 요소에 대한 단일 이벤트 핸들러
입력 필드가 여러 개인 폼에서 필드마다 handleChange 함수를 따로 만드는 것은 번거롭습니다. 이럴 때는 event.target.name을 활용해 하나의 처리 함수로 여러 입력 필드를 다룰 수 있습니다.
import { useState } from 'react';
function MultiInputForm() {
const [formValues, setFormValues] = useState({
username: '',
email: '',
password: ''
});
const handleChange = (event) => {
// (1) event.target에서 name과 value를 비구조화 할당으로 추출
const { name, value } = event.target;
setFormValues({
...formValues, // (2) 기존 formValues 객체를 복사
[name]: value // (3) 변경된 input의 name 속성에 해당하는 값만 업데이트
});
};
const handleSubmit = (event) => {
alert(`제출된 정보:\n사용자 이름: ${formValues.username}\n이메일: ${formValues.email}\n비밀번호: ${formValues.password}`);
event.preventDefault();
};
return (
<div style={{ border: '1px solid #6f42c1', padding: '20px', margin: '20px', borderRadius: '8px' }}>
<h2>회원가입 폼</h2>
<form onSubmit={handleSubmit}>
<div style={{ marginBottom: '15px' }}>
<label>
사용자 이름:
<input
type="text"
name="username"
value={formValues.username}
onChange={handleChange}
style={{ marginLeft: '10px', padding: '8px', fontSize: '16px', borderRadius: '4px', border: '1px solid #ccc' }}
/>
</label>
</div>
<div style={{ marginBottom: '15px' }}>
<label>
이메일:
<input
type="email"
name="email"
value={formValues.email}
onChange={handleChange}
style={{ marginLeft: '10px', padding: '8px', fontSize: '16px', borderRadius: '4px', border: '1px solid #ccc' }}
/>
</label>
</div>
<div style={{ marginBottom: '15px' }}>
<label>
비밀번호:
<input
type="password"
name="password"
value={formValues.password}
onChange={handleChange}
style={{ marginLeft: '10px', padding: '8px', fontSize: '16px', borderRadius: '4px', border: '1px solid #ccc' }}
/>
</label>
</div>
<button type="submit" style={{ padding: '8px 15px', fontSize: '16px', backgroundColor: '#6f42c1', color: 'white', border: 'none', borderRadius: '4px', cursor: 'pointer' }}>
가입하기
</button>
</form>
<p style={{ marginTop: '15px' }}>
입력된 사용자 이름: {formValues.username}<br />
입력된 이메일: {formValues.email}
</p>
</div>
);
}
export default MultiInputForm;formValues state는 여러 입력 필드의 값을 하나의 객체로 관리합니다. handleChange는 event.target.name으로 어떤 입력 필드가 바뀌었는지 알아내고, 그 필드의 값만 formValues 객체에서 바꿉니다. [name]: value처럼 객체 리터럴의 키를 대괄호로 감싸면, name이라는 글자가 아니라 변수 name에 담긴 값(예를 들어 'email')이 속성 이름이 됩니다. 이 문법을 계산된 속성 이름(computed property name)이라고 합니다. 이때 ...formValues로 기존 state를 복사하는 것이 중요합니다. 객체 state를 갱신할 때는 기존 객체를 직접 고치지 않는 불변성(immutability)을 지켜야 하며, 이 개념은 뒤의 장에서 더 자세히 다룹니다.
정리하면 제어 컴포넌트에서는 onChange가 입력 요소의 현재 DOM 값을 읽어 setter로 다음 상태를 요청하고, 다음 렌더에서 그 상태를 value로 넘겨 화면의 값과 React가 관리하는 값을 맞춥니다. 여러 입력을 하나의 처리 함수로 다룰 때는 name의 값으로 어떤 필드를 갱신할지 정하며, 이 흐름 덕분에 폼의 현재 값이 언제나 React의 state 한곳에서 관리됩니다. 이 예제의 가입하기 버튼은 state의 값을 알림창으로 보여 줄 뿐, 계정을 만들거나 서버에 보내지는 않습니다. 입력을 모으는 단계와 실제 회원가입을 처리하는 단계는 구별해야 합니다.
비제어 컴포넌트 (선택적)
React에서는 보통 제어 컴포넌트로 폼을 다루기를 권합니다. 하지만 파일 업로드나 외부 라이브러리 연동처럼 폼 요소의 값을 DOM에서 직접 가져와야 하는 상황도 있습니다. 이때는 비제어 컴포넌트를 쓰고, ref 속성으로 DOM 요소에 직접 접근합니다. 다음 예제는 파일을 선택한 뒤 제출하는 경우만 다루며, 선택하지 않았을 때의 처리와 실제 업로드는 생략했습니다.
import { useRef } from 'react';
function FileInputForm() {
const fileInputRef = useRef(null); // useRef 훅으로 ref 생성
const handleSubmit = (event) => {
event.preventDefault();
// ref.current를 통해 실제 DOM 요소에 접근하여 값 가져오기
alert(`선택된 파일: ${fileInputRef.current.files[0].name}`);
};
return (
<form onSubmit={handleSubmit}>
<input type="file" ref={fileInputRef} /> {/* ref 속성 연결 */}
<button type="submit">파일 제출</button>
</form>
);
}
// 실제 파일 업로드는 더 복잡한 서버 통신을 포함합니다.직접 실행하려면 위 컴포넌트를 FileInputForm.jsx에 저장하고 파일 끝에 export default FileInputForm;을 더한 뒤 App.jsx에서 렌더링합니다. 파일 하나를 선택하고 제출하면 알림창에 “선택된 파일: ”과 그 파일의 이름이 나타납니다. useRef 훅은 이처럼 DOM 요소에 직접 접근할 때 쓰며, 9장의 제어 컴포넌트와 비제어 컴포넌트에서 자세히 다룹니다. 다만 이 방법은 폼 데이터의 관점에서 보면 비제어 방식이므로, 가능하면 제어 컴포넌트를 쓰는 편이 React의 방식과 더 잘 맞습니다.
정리
- 제어 컴포넌트는 폼 요소의
value를 state에서 읽고,onChange에서 다음 값을 저장해 화면과 데이터를 같은 기준으로 맞춥니다. input,textarea,select는value를, 체크박스는checked를 state와 연결하며,textarea의 자식 텍스트나option의selected는 쓰지 않습니다.- 폼을 제출할 때는
event.preventDefault()로 페이지가 새로고침되는 기본 동작을 막습니다. - 여러 입력은
name속성으로 구별해{...formValues, [name]: value}처럼 바뀐 필드만 새 객체에서 교체합니다. - 파일 입력처럼 DOM에서 값을 읽어야 하는 비제어 요소는
useRef로 접근합니다. - 제어 입력의 값과 변경 처리 함수를 함께 지정하는 이유는 React 문서의 input에서 확인할 수 있습니다.
연습 문제
1. 두 필드를 차례로 입력하면
MultiInputForm의 사용자 이름에 “지수”를 입력한 뒤 이메일에 [email protected]을 입력합니다. 두 번의 변경이 formValues의 어느 속성을 바꾸고, 마지막에 세 속성은 각각 어떤 값일까요?
해설 보기
첫 변경은 name이 username이므로 그 속성만 바꾸고, 두 번째 변경은 email 속성만 바꿉니다. 두 번째 갱신에서도 기존 객체를 펼쳐 복사하므로 사용자 이름 “지수”가 남습니다.
마지막 formValues는 username이 “지수”, email이 [email protected], password가 빈 문자열입니다. 아래에는 “입력된 사용자 이름: 지수”와 “입력된 이메일: [email protected]”이 보입니다.
2. 입력의 이름이 state의 속성과 다르면
원래 이메일 입력에서 value={formValues.email}과 처리 함수는 그대로 두고, name="email"만 name="mail"로 바꿨다고 가정합니다. [email protected]을 입력하면 객체의 어느 속성이 바뀔까요? 입력칸과 아래의 이메일 표시가 갱신되지 않는 이유는 무엇일까요?
해설 보기
처리 함수는 읽은 이름을 그대로 속성 이름으로 쓰므로, formValues에 새 mail 속성을 만듭니다. 하지만 입력의 value와 아래 표시는 여전히 email 속성을 읽고, email은 빈 문자열이므로 다음 렌더에서 빈 값을 넘깁니다. 그래서 입력칸에 글자가 남지 않습니다.
name은 자동으로 email 속성에 연결되는 특별한 기능이 아니라, 이 처리 함수가 어떤 키를 쓸지 정하는 값일 뿐입니다. 확인한 뒤에는 원래 이름으로 되돌립니다.
3. 파일을 고르지 않고 제출하면
FileInputForm에서 파일을 고르지 않은 채 제출 버튼을 누르면 어떻게 될까요? 빈 선택도 처리하려면 처리 함수를 어떻게 바꿔야 할까요?
해설 보기
fileInputRef.current.files[0]이 undefined이므로, 거기서 .name을 읽는 순간 TypeError가 납니다. Chrome의 Console에는 Cannot read properties of undefined (reading 'name')이 나타나고 알림창은 뜨지 않습니다.
파일 이름을 읽기 전에 파일이 있는지 먼저 검사하면 됩니다. 아래처럼 바꿔도 실제 업로드는 여전히 하지 않습니다.
const handleSubmit = event => {
event.preventDefault();
const file = fileInputRef.current?.files?.[0];
if (!file) {
alert('파일을 먼저 선택하세요.');
return;
}
alert(`선택된 파일: ${file.name}`);
};다음 글 컴포넌트 라이프사이클에서는 useEffect로 컴포넌트가 화면에 나타나고 사라지는 시점에 작업을 실행하는 방법을 다룹니다.