문의 폼 만들기 (제어 컴포넌트 · 제출 상태 · 결과 · 리셋)

튜토리얼 — 문의 폼 만들기 (제어 컴포넌트 · 제출 상태 · 결과 · 리셋)
SignupForm에서 배운 흐름은 그대로입니다 — 입력 → 검증 → 제출 중 → 결과 화면 → 다시 작성. 이번엔 입력 종류를 둘 더 다뤄 봅니다: 드롭다운(<select>) 과 여러 줄 입력(<textarea>). 폼은 입력 종류가 무엇이든 다루는 방법이 같다는 걸 손으로 확인합니다.
개념
SignupForm은 <input> 두 개였습니다. 모든 입력이 제어 컴포넌트 — 값은 React 상태에 있고, onChange로 상태를 바꿉니다.
<select>와 <textarea>도 똑같습니다. HTML에서는 둘이 좀 특이해 보이지만, React에서는 전부 value + onChange 한 가지 패턴입니다.
<input value={form.name} onChange={handleChange} /> <select value={form.topic} onChange={handleChange}>…</select> <textarea value={form.message} onChange={handleChange} />
특히 <textarea>는 HTML에서는 <textarea>여러 줄</textarea>처럼 태그 사이에 내용을 쓰지만, React에서는 <input>처럼 value 속성에 줍니다. 한 번 외워 두면 됩니다.
그리고 handleChange를 셋 다 같이 쓰려면 이벤트 타입을 살짝 넓혀 줍니다.
React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement>
세 요소 모두 e.target에 name과 value를 가지고 있어서, 이름만 맞춰 두면 setForm({ ...form, [name]: value }) 한 줄로 다 처리됩니다.
함께 해보기
1단계 — 폼 모양과 초기값
먼저 폼 데이터의 모양과 초기값을 정합니다.
type Topic = "" | "bug" | "feature" | "etc"; type FormData = { name: string; email: string; topic: Topic; // select message: string; // textarea }; const INITIAL_FORM: FormData = { name: "", email: "", topic: "", message: "", };
topic은 빈 문자열을 "아직 안 골랐다"는 뜻으로 씁니다. INITIAL_FORM을 상수로 빼 두면 리셋이 깔끔해집니다 — setForm(INITIAL_FORM) 한 줄.
2단계 — 컴포넌트 만들기
components/ContactForm.tsx 파일을 만듭니다. SignupForm과 흐름은 똑같고, 입력 두 개(select, textarea)와 검증 두 개가 더 들어갑니다.
"use client"; import { useState } from "react"; type Topic = "" | "bug" | "feature" | "etc"; type FormData = { name: string; email: string; topic: Topic; message: string; }; const INITIAL_FORM: FormData = { name: "", email: "", topic: "", message: "", }; export default function ContactForm() { const [form, setForm] = useState<FormData>(INITIAL_FORM); const [error, setError] = useState(""); const [submitting, setSubmitting] = useState(false); const [submitted, setSubmitted] = useState(false); // input · select · textarea 셋을 한 핸들러로 function handleChange( e: React.ChangeEvent< HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement >, ) { const { name, value } = e.target; setForm({ ...form, [name]: value }); } async function handleSubmit(e: React.FormEvent) { e.preventDefault(); if (form.name.trim() === "") { setError("이름을 입력하세요."); return; } if (!form.email.includes("@")) { setError("이메일 형식이 올바르지 않습니다."); return; } if (form.topic === "") { setError("문의 분류를 선택하세요."); return; } if (form.message.trim().length < 10) { setError("문의 내용을 10자 이상 적어 주세요."); return; } setError(""); setSubmitting(true); await new Promise((resolve) => setTimeout(resolve, 800)); // 서버 흉내 setSubmitting(false); setSubmitted(true); } function handleReset() { setForm(INITIAL_FORM); setError(""); setSubmitted(false); } // 결과 화면 if (submitted) { const topicLabel = form.topic === "bug" ? "버그 신고" : form.topic === "feature" ? "기능 제안" : "기타"; return ( <div className="p-4 space-y-2"> <p className="text-green-700"> {form.name}님, 문의가 접수되었습니다! </p> <p className="text-sm text-gray-600">분류: {topicLabel}</p> <p className="text-sm whitespace-pre-wrap text-gray-600"> 내용: {form.message} </p> <button className="rounded bg-gray-600 px-3 py-1 text-white" onClick={handleReset} > 새 문의 작성 </button> </div> ); } // 입력 화면 return ( <form onSubmit={handleSubmit} className="p-4 space-y-2"> <input name="name" className="block w-full border px-2 py-1" value={form.name} onChange={handleChange} placeholder="이름" /> <input name="email" className="block w-full border px-2 py-1" value={form.email} onChange={handleChange} placeholder="이메일" /> <select name="topic" className="block w-full border px-2 py-1" value={form.topic} onChange={handleChange} > <option value="">문의 분류를 선택하세요</option> <option value="bug">버그 신고</option> <option value="feature">기능 제안</option> <option value="etc">기타</option> </select> <textarea name="message" className="block w-full border px-2 py-1" rows={4} value={form.message} onChange={handleChange} placeholder="문의 내용 (10자 이상)" /> {error && <p className="text-red-600">{error}</p>} <button type="submit" disabled={submitting} className="rounded bg-blue-500 px-3 py-1 text-white disabled:opacity-50" > {submitting ? "제출 중…" : "문의 보내기"} </button> </form> ); }
SignupForm과 비교해 보면 — 흐름은 그대로입니다. form 상태 하나, submitting/submitted/error 보조 상태 셋, handleChange/handleSubmit/handleReset 핸들러 셋, 결과 화면을 if (submitted)로 가르는 구조까지 동일합니다. 새로 들어간 것은 <select> · <textarea> 두 줄과 그에 맞춘 검증 두 줄, handleChange의 이벤트 타입 확장입니다.
3단계 — 페이지에 올리고 확인
app/contact/page.tsx 파일을 만듭니다.
import ContactForm from "@/components/ContactForm"; export default function ContactPage() { return ( <main style={{ padding: 24 }}> <h2>문의 폼</h2> <ContactForm /> </main> ); }
/contact를 엽니다.
- 빈 채로 제출 → "이름을 입력하세요." ✅
- 이름만 채우고 이메일
abc→ "이메일 형식이 올바르지 않습니다." ✅ - 분류 안 고르고 → "문의 분류를 선택하세요." ✅
- 내용 짧게 → "문의 내용을 10자 이상 적어 주세요." ✅
- 다 채우고 제출 → 버튼이 "제출 중…" 으로 바뀌며 비활성화, 0.8초 뒤 결과 화면(이름·분류·내용 표시). ✅
- "새 문의 작성" → 입력 화면으로 돌아가고, 모든 필드가 비어 있습니다. ✅
확인 ✅ — <select>도 <textarea>도 <input>과 같은 방식(value + onChange)으로 동작하고, 한 handleChange가 셋을 모두 처리합니다. 제출 상태·결과 화면·리셋 흐름은 SignupForm과 똑같이 작동합니다.
정리
<input>·<select>·<textarea>— 셋 다 제어 컴포넌트로 다루는 방법이 같습니다. (value+onChange)- 한
handleChange로 셋을 처리하려면 이벤트 타입을HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement로 넓힙니다. - 초기값을
INITIAL_FORM상수로 빼 두면 리셋이 한 줄로 깔끔해집니다. - 폼·제출 상태·결과 화면·리셋의 흐름은 입력 종류가 늘어나도 그대로입니다.
연습 거리
- 체크박스
<input type="checkbox" name="agree" checked={form.agree} onChange={...}>를 추가해 "개인정보 처리방침에 동의" 검증 넣기
(e.target.checked를 써야 합니다 — 입력 종류에 따라 읽는 속성이 다름) - 결과 화면에 "수정" 버튼을 추가해, 입력값을 그대로 둔 채 입력 화면으로 돌아가기 (
setSubmitted(false)만 호출) - 제출 중일 때 모든 입력칸도 함께
disabled={submitting}으로 잠그기 — 사용자가 제출 중에 내용을 바꾸지 못하게

댓글
댓글을 작성하려면 이 필요합니다.