이벤트와 폼 타이핑
ChangeEvent · FormEvent · KeyboardEvent와 입력 핸들러의 타입, controlled · uncontrolled 폼의 TypeScript 패턴, 그리고 27장 Server Actions FormData의 토대까지 다룹니다.
18장에서 빌트인 hook 들의 타입을 정리했습니다. 본 챕터는 컴포넌트 안에서 가장 자주 만나는 타이핑 — 이벤트 객체와 폼 입력입니다.
6장 (이벤트 핸들링)과 9장 (폼 다루기)에서 본 패턴을 TypeScript 위에 다시 올립니다. 그리고 27장 (Server Actions와 폼)에서 만날 <form action={fn}> + FormData의 새 모델은 본 챕터의 비제어 폼 패턴이 거의 그대로 이어집니다. 본 챕터에서 FormData 다루기에 익숙해지면 27장이 훨씬 가볍게 읽힙니다.
JavaScript로 짤 때는 e.target.value만 적으면 끝이었는데, TypeScript로 옮기면 e가 무슨 타입인지부터 정해야 합니다. 그 결정을 깔끔하게 내리는 법을 살펴봅니다.
React이벤트 객체의 타입 — React.XXXEvent
#
리액트의 합성 이벤트 객체는 모두 React.SyntheticEvent를 베이스로 하고, 이벤트 종류와 대상 엘리먼트에 따라 더 좁은 타입이 있습니다. 자주 쓰는 건 다음 다섯 가지 정도입니다.
| 이벤트 | 타입 |
|---|---|
| onClick | React.MouseEvent<HTMLButtonElement> |
| onChange (input) | React.ChangeEvent<HTMLInputElement> |
| onSubmit (form) | React.FormEvent<HTMLFormElement> |
| onKeyDown | React.KeyboardEvent<HTMLInputElement> |
| onFocus / onBlur | React.FocusEvent<HTMLInputElement> |
타입 인자 위치에 이벤트가 발생하는 엘리먼트를 적습니다. 이게 들어 있어야 e.currentTarget의 타입이 정확하게 좁혀집니다.
function NameInput() {
const [name, setName] = useState('');
const onChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setName(e.target.value); // string으로 자동 추론
};
const onKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === 'Enter') {
// ...
}
};
return <input value={name} onChange={onChange} onKeyDown={onKeyDown} />;
}e.target vs e.currentTarget
#
리액트 + TypeScript에서 의외로 많이 헷갈리는 부분입니다.
e.currentTarget— 이벤트 핸들러가 걸려 있는 엘리먼트. 타입이 정확하게 잡힙니다.e.target— 이벤트가 시작된 엘리먼트. 자식이 클릭되면 자식이 됩니다.
onClick={...}을 부모에 걸어 두고 e.target.value를 읽으면 자식이 들어올 수 있어 타입이 안 맞습니다. **input의 값을 읽을 때는 거의 항상 e.currentTarget.value**가 정답입니다.
const onChange = (e: React.ChangeEvent<HTMLInputElement>) => {
// 둘 다 동작은 하지만
console.log(e.target.value); // EventTarget — 좁혀져 있지만 의미는 모호
console.log(e.currentTarget.value); // HTMLInputElement — 더 안전
};onChange의 경우 target도 좁혀지긴 하지만, 버튼 / 리스트 처럼 위임 패턴을 쓸 때는 currentTarget만이 정답입니다. 헷갈릴 때는 항상 currentTarget으로 가는 게 안전합니다.
| 항목 | 타입 | 의미 |
|---|---|---|
e.currentTarget | T (타입 인자) | 핸들러가 걸린 엘리먼트 |
e.target | EventTarget | 이벤트가 실제로 시작된 엘리먼트 (위임 시 자식) |
인라인 핸들러 — 매개변수 타입을 쓸 필요가 없는 경우 #
JSX 안에서 인라인으로 쓰는 핸들러는 매개변수 타입을 적지 않아도 추론됩니다. 부모 prop 타입 (onChange)이 자식 함수 시그니처를 알려 주기 때문입니다.
<input
onChange={(e) => setQuery(e.target.value)} // e의 타입이 자동 추론됨
/>핸들러가 짧으면 인라인이 깔끔합니다. 길어지면 컴포넌트 본문으로 빼고, 그때 (e: React.ChangeEvent<HTMLInputElement>) => ...로 명시해 주세요. 두 패턴을 자유롭게 오갈 수 있다면 충분합니다.
제어 폼 (controlled form) #
가장 흔한 패턴부터. 입력값을 상태로 두고, 매 입력마다 setter를 부릅니다.
import { useState } from 'react';
function NameForm() {
const [name, setName] = useState('');
const onSubmit = (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
alert(`안녕, ${name}!`);
};
return (
<form onSubmit={onSubmit}>
<input
value={name}
onChange={(e) => setName(e.target.value)}
/>
<button type="submit">제출</button>
</form>
);
}여기서 TypeScript가 잡아 주는 부분이 두 곳입니다.
setName(e.target.value)에서value는 항상string.setName도string만 받으니 안전.onSubmit의e: FormEvent<HTMLFormElement>가e.preventDefault()를 자동완성해 줌.
여러 필드를 객체 하나로 묶기 #
필드가 늘어나면 useState를 매번 부르는 건 번거롭습니다. 객체 상태 + 공통 onChange가 흔한 답입니다.
type SignupForm = {
email: string;
password: string;
agree: boolean;
};
function SignupPage() {
const [form, setForm] = useState<SignupForm>({
email: '',
password: '',
agree: false,
});
const onChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const { name, value, type, checked } = e.currentTarget;
setForm((prev) => ({
...prev,
[name]: type === 'checkbox' ? checked : value,
}));
};
return (
<form>
<input name="email" value={form.email} onChange={onChange} />
<input name="password" type="password" value={form.password} onChange={onChange} />
<input name="agree" type="checkbox" checked={form.agree} onChange={onChange} />
</form>
);
}name 속성을 prev의 키로 쓰는 패턴이 핵심입니다. 다만 이 방식은 타입 안전이 살짝 헐렁 해집니다. name="email"을 name="emial"로 오타 내도 컴파일러가 잡지 못합니다. 폼이 커지면 본 챕터 뒤에서 다룰 라이브러리 도움을 받는 게 안전합니다.
비제어 폼 (uncontrolled form) — FormData 사용
#
폼이 단순 제출 용도라면 매 입력마다 setState를 굳이 안 해도 됩니다. 제출 시점에 FormData로 한 번에 읽는 패턴이 가볍고, React 19의 Server Actions와도 자연스럽게 어울립니다.
function ContactForm() {
const onSubmit = (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
const formData = new FormData(e.currentTarget);
const email = formData.get('email'); // FormDataEntryValue | null
const message = formData.get('message');
if (typeof email !== 'string' || typeof message !== 'string') return;
// 안전하게 string으로 좁혀진 뒤 사용
sendMessage({ email, message });
};
return (
<form onSubmit={onSubmit}>
<input name="email" type="email" required />
<textarea name="message" required />
<button type="submit">보내기</button>
</form>
);
}formData.get('email')의 반환 타입은 **FormDataEntryValue | null**입니다. FormDataEntryValue는 string | File이라, 텍스트 입력값을 다룰 때는 typeof === 'string'으로 한 번 좁히는 습관을 들여야 합니다.
비제어 폼의 장점은 코드가 깔끔하다는 점입니다. 단점은 입력값에 즉각 반응 (글자 수 표시, 실시간 검증) 하기 어렵다는 점입니다. 간단한 제출 폼은 비제어, 즉각 피드백이 필요한 폼은 제어가 일반적인 가이드입니다.
27장 Server Actions로의 다리 #
위 비제어 패턴이 그대로 27장 (Server Actions와 폼)의 새 모델로 이어집니다. React 19의 <form action={serverFn}> 안에서는 onSubmit + preventDefault의 한 사이클이 사라지고, 서버 함수가 formData: FormData를 직접 받습니다.
'use server';
async function sendContactAction(formData: FormData) {
const email = formData.get('email');
const message = formData.get('message');
if (typeof email !== 'string' || typeof message !== 'string') {
return { error: '잘못된 입력' };
}
// 서버에서 DB 저장 / 이메일 발송 등
await saveContact({ email, message });
}
// 클라이언트
<form action={sendContactAction}>
<input name="email" type="email" required />
<textarea name="message" required />
<button type="submit">보내기</button>
</form>FormData와 typeof === 'string' 좁히기 패턴이 그대로 살아 있습니다. 본 챕터에서 익힌 비제어 모델이 4부의 새 모델에서도 그대로 통합니다.
FormEvent.currentTarget.elements — 이름으로 꺼내기
#
비제어 폼에서 FormData 대신 e.currentTarget.elements.email처럼 이름으로 직접 꺼낼 수도 있습니다. 다만 TypeScript는 폼 안에 어떤 input이 있는지 모르기 때문에 다음 두 단계가 필요합니다.
type FormElements = HTMLFormControlsCollection & {
email: HTMLInputElement;
message: HTMLTextAreaElement;
};
type ContactFormElement = HTMLFormElement & {
readonly elements: FormElements;
};
function ContactForm() {
const onSubmit = (e: React.FormEvent<ContactFormElement>) => {
e.preventDefault();
const email = e.currentTarget.elements.email.value; // string
const message = e.currentTarget.elements.message.value; // string
sendMessage({ email, message });
};
return (
<form onSubmit={onSubmit}>
<input name="email" />
<textarea name="message" />
<button type="submit">보내기</button>
</form>
);
}이 방식은 타입이 정확하지만 보일러플레이트가 큽니다. 폼이 한두 개라면 FormData 쪽이, 여러 폼에서 공통으로 같은 모양을 쓴다면 elements 쪽이 어울립니다.
폼이 커지면 라이브러리를 고려 #
필드가 5~6개를 넘어가면 손으로 타입을 관리하는 비용이 빠르게 커집니다. 실무에서는 다음 라이브러리들이 흔합니다.
- react-hook-form + zod — register / handleSubmit이 타입을 거의 자동으로 잡아 줍니다. zod 스키마로 검증과 타입을 한 번에 정의하는 패턴이 인기입니다. 21장 (fetch와 API 응답 타이핑)에서 zod를 다시 만납니다.
- Server Actions + zod (Next.js) — 폼을 비제어로 두고 서버에서 검증하는 패턴. 클라이언트에서는 거의 코드를 안 짭니다. 27장 (Server Actions와 폼)의 모델.
이 책의 본문은 라이브러리 없이 빌트인만 다룹니다. 실제 프로젝트에서는 위 둘 중 하나를 선택해 두고 가는 게 시간 낭비를 줄여 줍니다.
Submit 핸들러의 반환 타입 #
Submit 핸들러를 비동기로 짜면 반환 타입이 Promise<void>가 됩니다. JSX의 onSubmit prop은 그 두 형태를 모두 받게 정의되어 있습니다.
const onSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
const formData = new FormData(e.currentTarget);
await fetch('/api/contact', { method: 'POST', body: formData });
};
<form onSubmit={onSubmit}>...</form> // OK
비동기로 쓸 때 주의할 점 하나 — e.preventDefault()를 await보다 먼저 호출해야 합니다. await 뒤로 넘어가면 이미 폼이 제출되어 페이지 이동이 시작될 수 있습니다.
직접 해보기 #
9장에서 만든 가입 폼의 핵심 부분을 TypeScript로 다시 짜고, FormData 기반 비제어 패턴으로 만들어 봅니다.
src/SignupForm.tsx:
import { useState } from 'react';
type SubmittedData = {
name: string;
email: string;
agreed: boolean;
};
function SignupForm() {
const [submitted, setSubmitted] = useState<SubmittedData | null>(null);
const [error, setError] = useState<string | null>(null);
const onSubmit = (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
const formData = new FormData(e.currentTarget);
const name = formData.get('name');
const email = formData.get('email');
const agreed = formData.get('agreed');
if (typeof name !== 'string' || typeof email !== 'string') {
setError('이름과 이메일을 모두 입력해 주세요.');
return;
}
if (agreed !== 'on') {
setError('약관에 동의해 주세요.');
return;
}
setError(null);
setSubmitted({ name, email, agreed: true });
};
return (
<div style={{ padding: '16px', border: '1px solid #ccc', borderRadius: '8px' }}>
<h2>회원 가입</h2>
<form onSubmit={onSubmit}>
<div>
<label>이름: </label>
<input name="name" required />
</div>
<div>
<label>이메일: </label>
<input name="email" type="email" required />
</div>
<div>
<label>
<input name="agreed" type="checkbox" />
약관에 동의합니다
</label>
</div>
<button type="submit" style={{ marginTop: '8px' }}>가입</button>
</form>
{error && <p style={{ color: 'red', marginTop: '8px' }}>{error}</p>}
{submitted && (
<pre style={{ marginTop: '16px', background: '#f4f4f4', padding: '8px' }}>
{JSON.stringify(submitted, null, 2)}
</pre>
)}
</div>
);
}
export default SignupForm;저장하고 동작을 확인해 보세요. 이름 / 이메일 / 체크박스를 모두 채우고 제출하면 결과가 JSON으로 표시됩니다. 잘못 채우면 빨간 에러 메시지가 뜹니다.
체크박스의 경우 FormData.get('agreed')는 체크돼 있으면 'on' 문자열을, 안 돼 있으면 null을 돌려준다는 점이 함정 포인트입니다. 위 코드에서는 agreed !== 'on'으로 좁혀 처리했습니다.
연습문제 #
- 위
SignupForm을 controlled 폼 버전으로도 짜 보세요.useState<{ name: string; email: string; agreed: boolean }>로 한 객체에 묶고, 공통onChange로 받아 처리합니다. 어떤 코드가 더 짧고 어떤 게 더 안전한지 비교해 보세요. currentTargetvstarget차이 직접 만나기. 부모<div onClick={...}>에 핸들러를 걸고, 안에<button>을 두세요. 버튼을 클릭하면e.target은 button,e.currentTarget은 div 임을 콘솔로 확인합니다.target의 타입이EventTarget으로 넓게 잡혀.value같은 접근이 막히는 것도 관찰합니다.FormData와 27장 Server Actions 모델 비교. 위SignupForm의 onSubmit 안 코드를 별도 함수submitSignup(formData: FormData)로 추출하고,onSubmit안에서e.preventDefault()후 그 함수를 호출하도록 만들어 보세요. 27장에서 만날 Server Action의 시그니처와 거의 동일한 모양이 됩니다.
한 줄 요약: 이벤트 타입은
React.XXXEvent<엘리먼트>형태로. input 값을 읽을 때는e.currentTarget.value가 안전. 인라인 핸들러는 추론에 맡기고, 본문으로 빼면 명시. 제어 폼은 단일 필드 useState, 여러 필드는 객체 + name 활용. 비제어 폼은FormData가 가장 깔끔하고 27장 Server Actions의 모델로 그대로 이어진다.formData.get()결과는FormDataEntryValue | null이라typeof === 'string'으로 한 번 좁혀 쓴다. 폼이 커지면 react-hook-form + zod 같은 라이브러리.
다음 챕터 #
다음 20장 Context와 제네릭 컴포넌트에서는 12장 (useContext)의 JavaScript 패턴을 TypeScript 위로 다시 올립니다. createContext의 타입 인자 패턴, 안전한 useContext 헬퍼, 그리고 List / Select 같은 재사용 컴포넌트를 만드는 제네릭 패턴과 다형 컴포넌트의 as prop까지 다룹니다.