폼 다루기란?
폼(forms) : 사용자가 데이터를 입력하고, 제출할 수 있도록 하는 UI 요소
ex) 텍스트 필드, 체크박스, 라디오버튼, 드롭 다운 목록 등
폼 요소를 다루는 방법
- 제어 컴포넌트 방식
- React의 상태를 활용해서 폼 요소를 제어하는 방식
- 비제어 컴포넌트 방식
- React의 상태를 활용하지 않고 DOM 자체에서 상태를 관리하게 하는 방식
제어 컴포넌트 - 한 줄 입력 요소
readOnly, disabled : 사용자가 폼 요소의 value 값을 직접 수정하는 것을 막을 때 사용하는 속성
∴ 초기값을 디스플레이하는 용도로만 사용
ㄴ readOnly : 데이터를 서버에 보낼 때 포함 / disabled : 데이터가 전송되지 않고 제외됨
* 한 개의 한 줄 입력 요소
// Input.tsx
import { useState } from "react";
export default function Input() {
const [input, setInput] = useState("");
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setInput(e.target.value);
};
return (
<>
<form>
<h1>Input: {input}</h1>
<input type="text" value={input} onChange={handleInputChange} />
</form>
</>
);
}
^ React의 상태(state)와 입력창의 값(value)을 동기화하는 코드
* 다중 Input 관리
1인 1상태 방식 : 입력 요소가 많아질수록 useState와 핸들러 함수가 계속 추가됨 --> 코드가 길어짐
∴ 객체 통합 방식 사용 --> 상태 하나와 함수 하나로 모든 입력 요소를 처리
ㄴ 객체 속성의 키 값과 입력 요소의 name 속성 값을 일치시킴
* 여러 개의 한 줄 입력 요소
// Input.tsx
import { useState } from "react";
export default function Input() {
const [formState, setFormState] = useState({
email: "",
password: ""
});
const handleFormStateChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setFormState((prevFormState) => ({
...prevFormState,
[e.target.name] : e.target.value,
}))
}
return (
<>
<form>
<h1>Input: {formState.email} / Pw: {formState.password}</h1>
<input type="text" name="email" value={formState.email} onChange={handleFormStateChange} />
<input type="password" name="password" value={formState.password} onChange={handleFormStateChange} />
</form>
</>
);
}
^ name="email" / name="password"
제어 컴포넌트 - 체크박스
* 한 개의 체크박스
// CheckBox.tsx
import { useState } from "react";
export default function CheckBox () {
const [chk1, setChk1] = useState(false);
const handleChkChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setChk1(e.target.checked);
};
return (
<div>
<input type="checkbox" id="item1" checked={chk1} onChange={handleChkChange} />
<label htmlFor="item1">아이템 1 ({chk1 ? "선택됨" : "선택되지 않음"})</label>
</div>
);
}
^ React의 상태(state)와 입력창의 값(value)을 동기화하는 코드
* 여러 개의 체크박스
// CheckBox.tsx
import { useState } from "react";
export default function CheckBox () {
const [formState, setFormState] = useState({
chk1: false,
chk2: false
});
const handleFormStateChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setFormState((prevFormState) => ({
...prevFormState,
[e.target.name] : e.target.checked
}))
};
return (
<div>
<input type="checkbox" id="item1" name="chk1" checked={formState.chk1} onChange={handleFormStateChange} />
<label htmlFor="item1">아이템 1 ({formState.chk1 ? "선택됨" : "선택되지 않음"})</label>
<input type="checkbox" id="item2" name="chk2" checked={formState.chk2} onChange={handleFormStateChange} />
<label htmlFor="item2">아이템 2 ({formState.chk2 ? "선택됨" : "선택되지 않음"})</label>
</div>
);
}
^ name="chk1" / name="chk2"
제어 컴포넌트 - 라디오버튼
* 한 개의 라디오버튼
// Radio.tsx
import { useState } from "react";
export default function Radio() {
const [selectedValue, setSelectedValue] = useState("option1");
const handleRadioChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setSelectedValue(e.target.value);
};
return (
<div>
<label>
<input type="radio" name="option" value="option1" checked={selectedValue === "option1"} onChange={handleRadioChange} />
옵션 1
</label>
<label>
<input type="radio" name="option" value="option2" checked={selectedValue === "option2"} onChange={handleRadioChange} />
옵션 2
</label>
<label>
<input type="radio" name="option" value="option3" checked={selectedValue === "option3"} onChange={handleRadioChange} />
옵션 3
</label>
</div>
);
}
* 라디오버튼과 객체 상태 관리
- 텍스트 인풋
- 이름, 이메일, 비밀번호는 각각 독립적인 데이터
- --> { name, email, pw }라는 객체가 필요
- 라디오버튼
- 남성, 여성, 선택 안 함 버튼이 3개 있어도, 최종적으로 필요한 데이터는 '성별'이라는 하나의 값
- --> 그룹 전체가 하나의 정보를 담기 때문에 useState("")라는 문자열 상태 하나면 충분
* 여러 개의 라디오버튼
// Radio.tsx
import { useState } from "react";
export default function Radio() {
const [formState, setFormState] = useState({
option: "",
color: ""
});
const handleFormStateChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setFormState((prevFormState) => ({
...prevFormState,
[e.target.name] : e.target.value
}))
};
return (
<section>
<div>
<label>
<input
type="radio"
name="option"
value="option1"
checked={formState.option === "option1"}
onChange={handleFormStateChange}
/>
옵션 1
</label>
<label>
<input
type="radio"
name="option"
value="option2"
checked={formState.option === "option2"}
onChange={handleFormStateChange}
/>
옵션 2
</label>
<label>
<input
type="radio"
name="option"
value="option3"
checked={formState.option === "option3"}
onChange={handleFormStateChange}
/>
옵션 3
</label>
</div>
<div>
<label>
<input
type="radio"
name="color"
value="color1"
checked={formState.color === "color1"}
onChange={handleFormStateChange}
/>
컬러 옵션 1
</label>
<label>
<input
type="radio"
name="color"
value="color2"
checked={formState.color === "color2"}
onChange={handleFormStateChange}
/>
컬러 옵션 2
</label>
<label>
<input
type="radio"
name="color"
value="color3"
checked={formState.color === "color3"}
onChange={handleFormStateChange}
/>
컬러 옵션 3
</label>
</div>
</section>
);
}
^ name="option" / name="color"
제어 컴포넌트 - 여러 줄 입력 요소
HTMLTextAreaElemet 타입
* 한 개의 여러 줄 입력 요소
// Textarea.tsx
import { useState } from "react";
export default function Textarea() {
const [text, setText] = useState("");
const handleTextChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
setText(e.target.value);
};
return (
<div>
<textarea value={text} onChange={handleTextChange}/>
<p>입력된 텍스트: {text}</p>
</div>
);
}
* 여러 개의 여러 줄 입력 요소
// Textarea.tsx
import { useState } from "react";
export default function Textarea() {
const [formState, setFormState] = useState({
desc: "",
introduce: ""
})
const handleFormStateChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
setFormState((prevFormState) => ({
...prevFormState,
[e.target.name] : e.target.value
}))
}
return (
<div>
<textarea name="desc" value={formState.desc} onChange={handleFormStateChange}/>
<p>입력된 텍스트: {formState.desc}</p>
<textarea name="introduce" value={formState.introduce} onChange={handleFormStateChange}/>
<p>입력된 텍스트: {formState.introduce}</p>
</div>
);
}
비제어 컴포넌트 - 한 줄 입력 요소
useRef 훅의 기본적인 문법 형태
const ref = useRef<Type>(initialState)
- Type
- 타입 추론에 의해서 생략 가능
- 태그와 연결할 때는 구체적인 타입을 적어주는 것이 좋음
* 비제어 컴포넌트 방식은 실시간으로 사용자가 입력한 값을 가져올 수는 없음
∵ 상태(useState)를 사용하지 않기 때문
ㄴ 참조된 DOM 노드에 직접 접근하여 필요할 때만 입력 요소에 입력된 값을 조회할 수 있음
* 한 개의 한 줄 입력 요소
// Input.tsx
import { useRef } from "react";
export default function Input() {
const inputRef = useRef<HTMLInputElement>(null);
const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
console.log(inputRef.current?.value);
};
return (
<form onSubmit={handleSubmit}>
<input type="text" ref={inputRef} />
<button type="submit">Submit</button>
</form>
);
}
* 여러 데이터를 하나의 객체로 관리하는 제어 방식과 달리, 비제어 방식에서는 ref 객체 하나가 단일 DOM 노드를 가리키도록 설계하는 것이 정석
비제어 컴포넌트 - 체크박스
// Checkbox.tsx
import { useRef } from "react";
export default function Checkbox() {
const privacyRef = useRef<HTMLInputElement>(null);
const termsRef = useRef<HTMLInputElement>(null);
const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
const privacy = privacyRef.current?.checked;
if (!privacy) {
alert("개인정보에 동의해주세요");
return;
}
const terms = termsRef.current?.checked;
if (!terms) {
alert("약관에 동의해주세요");
return;
}
console.log(privacyRef.current?.checked);
console.log(termsRef.current?.checked);
};
return (
<>
<form onSubmit={handleSubmit}>
<div>
<input type="checkbox" ref={privacyRef} />
<label>개인정보동의</label>
</div>
<div>
<input type="checkbox" ref={termsRef} />
<label>약관동의</label>
</div>
<button type="submit">Submit</button>
</form>
</>
);
}
^ privacyRef / termsRef
// Checkbox.tsx
import { useRef } from "react";
export default function Checkbox() {
const fruitRef = useRef<HTMLInputElement[]>([]);
const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
const selectedFruits = fruitRef.current
.filter((fruit) => fruit.checked)
.map((fruit) => fruit.value);
console.log(selectedFruits);
};
return (
<>
<form onSubmit={handleSubmit}>
<div>
<input
type="checkbox"
value={"사과"}
ref={(el) => {
fruitRef.current[0] = el!;
}}
/>
<label>사과</label>
</div>
<div>
<input
type="checkbox"
value={"바나나"}
ref={(el) => {
fruitRef.current[1] = el!;
}}
/>
<label>바나나</label>
</div>
<div>
<input
type="checkbox"
value={"오렌지"}
ref={(el) => {
fruitRef.current[2] = el!;
}}
/>
<label>오렌지</label>
</div>
<button type="submit">Submit</button>
</form>
</>
);
}
^ ref 배열과 콜백 함수를 이용해 여러 체크박스의 선택된 값을 한 번에 수집
비제어 컴포넌트 - 라디오버튼
new FormData() : name 속성이 붙은 모든 입력값을 (key: name, value: 값) 형태로 정리해서 뽑아줌
// Radio.tsx
import { useRef } from "react";
export default function Radio() {
const formElRef = useRef<HTMLFormElement>(null);
const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
const formData = new FormData(formElRef.current!);
console.log(formData.get("option"));
};
return (
<form ref={formElRef} onSubmit={handleSubmit}>
<div>
<label>
<input type="radio" name="option" value="option1" defaultChecked />
옵션 1
</label>
</div>
<div>
<label>
<input type="radio" name="option" value="option2" />
옵션 2
</label>
</div>
<div>
<label>
<input type="radio" name="option" value="option3" />
옵션 3
</label>
</div>
<button type="submit">제출</button>
</form>
);
}
^ formData.get("option") --> name이 option인 요소들 중에서 현재 선택된 것의 value만 추출
비제어 컴포넌트 - 여러 줄 입력 요소
HTMLTextAreaElemet 타입
// Textarea.tsx
import { useRef } from "react";
export default function Textarea() {
const descRef = useRef<HTMLTextAreaElement>(null);
const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
console.log(descRef.current?.value);
};
return (
<>
<form onSubmit={handleSubmit}>
<textarea name="desc" ref={descRef} />
<button type="submit">Submit</button>
</form>
</>
);
}
폼 제어 고급 - ref 전달하기
ref 객체는 결국 JSX 요소의 참조 관계를 형성하는 것이기 때문에 자바스크립트로 따지면 querySelector() 메소드와 동일한 역할을 한다고 생각
ref 속성을 자식 컴포넌트로 전달할 때 버전별 문법 차이
1. React 19
// App.tsx
import { useRef } from "react";
import Input from "./components/Input";
export default function App() {
const inputRef = useRef<HTMLInputElement>(null);
const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
};
return (
<>
<form onSubmit={handleSubmit}>
<Input ref={inputRef} />
<button type="submit">제출</button>
</form>
</>
);
}
// Input.tsx
import { RefObject } from "react";
export default function Input({
ref,
}: {
ref: RefObject<HTMLInputElement | null>;
}) {
return (
<>
<input type="text" ref={ref} />
</>
);
}
2. React 18
forwardRef() 함수 사용
// App.tsx
import { useRef } from "react";
import Input from "./components/Input";
export default function App() {
const inputRef = useRef<HTMLInputElement>(null);
const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
};
return (
<>
<form onSubmit={handleSubmit}>
<Input type="password" ref={inputRef} />
<button type="submit">제출</button>
</form>
</>
);
}
// Input.tsx
import { forwardRef } from "react";
export default forwardRef<HTMLInputElement, { type: string }>(function Input(
{ type },
ref,
) {
return (
<>
<input type={type} ref={ref} />
</>
);
});
폼 제어 고급 - 커스텀 훅
제어 컴포넌트 방식은 모든 입력 요소마다 상태(state)와 업데이트 함수(setter)를 한 쌍으로 선언해야 함
∴ 입력 폼이 많아지거나 복잡해질수록 코드의 가독성이 떨어지고 유지보수가 어려워짐
--> 이를 해결하기 위해 반복되는 로직을 별도의 함수로 분리하여 재사용하는 커스텀 훅 패턴이 권장됨
커스텀 훅(Custom Hook) : React에서 제공하는 훅을 조합하여 나만의 훅을 만드는 것
ㄴ 코드 다이어트, 범용성 확보 및 유지보수 용이
// App.tsx
import useInput from "./hooks/useInput";
export default function App() {
const { value: email, handleValueChange: handleEmailChange } = useInput("");
const { value: password, handleValueChange: handlePasswordChange } = useInput("");
const { value: name, handleValueChange: handleNameChange } = useInput("");
const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
console.log({ email, password, name });
};
return (
<>
<form onSubmit={handleSubmit}>
<input
type="email"
placeholder="email"
value={email}
onChange={handleEmailChange}
/>
<input
type="password"
placeholder="password"
value={password}
onChange={handlePasswordChange}
/>
<input
type="text"
placeholder="name"
value={name}
onChange={handleNameChange}
/>
<button type="submit">제출</button>
</form>
</>
);
}
// hooks/useInput.tsx
import { useState } from "react";
export default function useInput(initialValue = "") {
const [value, setValue] = useState(initialValue);
const handleValueChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setValue(e.target.value);
};
return {
value,
handleValueChange
};
}
벨리데이션 - 기본
벨리데이션(Validation, 유효성 검사) : 폼 요소의 값이 서비스에서 정한 규칙에 맞는지 확인하는 과정
- required
- 필수 입력
- minLength={num}
- 최소 길이 제한
- maxLength={num}
- 최대 길이 제한
벨리데이션 - 커스텀
커스텀 벨리데이션(Custom Validation) : HTML5 표준 속성을 넘어, 개발자가 직접 정의한 비즈니스 로직을 통해 데이터의 유효성을 검사하는 것
ㄴ 에러 핸들링도 커스텀 가능
// App.tsx
import useInput from "./hooks/useInput";
import { validateEmail, validatePassword, validateName } from "./utils/validation";
export default function App() {
const { value: email, handleValueChange: handleEmailChange } = useInput("");
const { value: password, handleValueChange: handlePasswordChange } =
useInput("");
const { value: name, handleValueChange: handleNameChange } = useInput("");
const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
// 이메일 검증
const emailError = validateEmail(email);
if (emailError) {
alert(emailError);
return;
}
// 비밀번호 검증
const passwordError = validatePassword(password);
if (passwordError) {
alert(passwordError);
return;
}
// 이름 검증
const nameError = validateName(name);
if (nameError) {
alert(nameError);
return;
}
console.log({ email, password, name });
};
return (
<>
<form onSubmit={handleSubmit}>
<input
type="email"
placeholder="email"
value={email}
onChange={handleEmailChange}
required
/>
<input
type="password"
placeholder="password"
value={password}
onChange={handlePasswordChange}
minLength={4}
maxLength={6}
required
/>
<input
type="text"
placeholder="name"
value={name}
onChange={handleNameChange}
required
/>
<button type="submit">제출</button>
</form>
</>
);
}
// utils/validation.ts
export const validateEmail = (email: string) => {
if (!email) return "이메일은 필수입니다.";
if (!/^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$/.test(email))
return "유효한 이메일 주소를 입력하세요.";
if (!/^[a-zA-Z0-9._%+-]+@(gmail\.com|naver\.com)$/.test(email)) {
return "이메일 주소는 gmail.com 또는 naver.com 형식만 허용됩니다.";
}
};
export const validatePassword = (password: string) => {
if (!password) return "비밀번호는 필수입니다.";
if (password.length < 8) return "비밀번호는 8자 이상이어야 합니다.";
if (!/[A-Za-z]/.test(password))
return "비밀번호에는 최소한 하나의 영문자가 포함되어야 합니다.";
if (!/[0-9]/.test(password))
return "비밀번호에는 최소한 하나의 숫자가 포함되어야 합니다.";
if (!/[!@#$%^&*(),.?":{}|<>]/.test(password))
return "비밀번호에는 최소한 하나의 특수문자가 포함되어야 합니다.";
};
export const validateName = (name: string) => {
if (!name) return "사용자 이름은 필수입니다.";
if (name.length < 3 || name.length > 20)
return "사용자 이름은 3~20자 사이여야 합니다.";
if (!/^[A-Za-z0-9]+$/.test(name))
return "사용자 이름은 영문자와 숫자만 포함해야 합니다.";
};
벨리데이션 - 커스텀 훅 통합
커스텀 훅과 커스텀 벨리데이션을 통합
// App.tsx
import useInput from "./hooks/useInput";
import {
validateEmail,
validateName,
validatePassword,
} from "./utils/validation";
export default function App() {
const { value: email, handleValueChange: handleEmailChange, error: emailError } = useInput("", validateEmail);
const { value: password, handleValueChange: handlePasswordChange, error: passwordError } = useInput("", validatePassword);
const { value: name, handleValueChange: handleNameChange, error: nameError } = useInput("", validateName);
const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
// 이메일 검증
if (emailError) {
alert(emailError);
return;
}
// 비밀번호 검증
if (passwordError) {
alert(passwordError);
return;
}
// 이름 검증
if (nameError) {
alert(nameError);
return;
}
console.log({ email, password, name });
};
return (
<>
<form onSubmit={handleSubmit}>
<input
type="email"
placeholder="email"
value={email}
onChange={handleEmailChange}
required
/>
{emailError && <p>{emailError}</p>}
<input
type="password"
placeholder="password"
value={password}
onChange={handlePasswordChange}
required
/>
{passwordError && <p>{passwordError}</p>}
<input
type="text"
placeholder="name"
value={name}
onChange={handleNameChange}
required
/>
{nameError && <p>{nameError}</p>}
<button type="submit">제출</button>
</form>
</>
);
}
// hooks/useInput.tsx
import { useState } from "react";
export default function useInput(
initialValue = "",
validateFn: (value: string) => string | undefined,
) {
const [error, setError] = useState("");
const [value, setValue] = useState(initialValue);
const handleValueChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setValue(e.target.value);
setError(validateFn(e.target.value) || "");
};
return {
value,
handleValueChange,
error
};
}
// utils/validation.ts
export const validateEmail = (email: string) => {
if (!email) return "이메일은 필수입니다.";
if (!/^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$/.test(email))
return "유효한 이메일 주소를 입력하세요.";
if (!/^[a-zA-Z0-9._%+-]+@(gmail\.com|naver\.com)$/.test(email)) {
return "이메일 주소는 gmail.com 또는 naver.com 형식만 허용됩니다.";
}
};
export const validatePassword = (password: string) => {
if (!password) return "비밀번호는 필수입니다.";
if (password.length < 8) return "비밀번호는 8자 이상이어야 합니다.";
if (!/[A-Za-z]/.test(password))
return "비밀번호에는 최소한 하나의 영문자가 포함되어야 합니다.";
if (!/[0-9]/.test(password))
return "비밀번호에는 최소한 하나의 숫자가 포함되어야 합니다.";
if (!/[!@#$%^&*(),.?":{}|<>]/.test(password))
return "비밀번호에는 최소한 하나의 특수문자가 포함되어야 합니다.";
};
export const validateName = (name: string) => {
if (!name) return "사용자 이름은 필수입니다.";
if (name.length < 3 || name.length > 20)
return "사용자 이름은 3~20자 사이여야 합니다.";
if (!/^[A-Za-z0-9]+$/.test(name))
return "사용자 이름은 영문자와 숫자만 포함해야 합니다.";
};'React > 타입스크립트로 배우는 리액트(React.js) : 기초부터 최신 기술까지' 카테고리의 다른 글
| 섹션 12. 사이드 이펙트와 컴포넌트 최적화 (0) | 2026.05.22 |
|---|---|
| 섹션 11. 할 일 관리 앱 (TODO LIST) (0) | 2026.05.14 |
| 섹션 9. (프로젝트) 계산기 만들기 (0) | 2026.05.02 |
| 섹션 8. 컴포넌트 스타일링 (2) (0) | 2026.04.21 |
| 섹션 8. 컴포넌트 스타일링 (1) (0) | 2026.04.16 |