#3 hooks 타이핑 에서 빌트인 hook들의 타입을 정리했습니다. 이번 글은 컴포넌트 안에서 가장 자주 만나는 타이핑 — 이벤트 객체와 폼 입력입니다.
자바스크립트로 짤 때는 e.target.value 만 적으면 끝났는데, 타입스크립트로 옮기면 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
리액트 + 타입스크립트에서 의외로 많이 헷갈리는 부분입니다.
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 으로 가는 게 안전해요.
인라인 핸들러 — 매개변수 타입을 쓸 필요가 없는 자리
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>
);
}여기서 타입스크립트가 잡아 주는 부분이 두 곳입니다.
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 로 한 번에 읽는 패턴이 가볍고, 리액트 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' 으로 한 번 좁히는 습관을 들여야 해요.
비제어 폼의 장점은 코드가 깔끔하다는 점입니다. 단점은 입력값에 즉각 반응(글자 수 표시, 실시간 검증)하기 어렵다는 점이에요. 간단한 제출 폼은 비제어, 즉각 피드백이 필요한 폼은 제어가 일반적인 가이드입니다.
FormEvent.currentTarget.elements — 이름으로 꺼내기
비제어 폼에서 FormData 대신 e.currentTarget.elements.email 처럼 이름으로 직접 꺼낼 수도 있습니다. 다만 타입스크립트는 폼 안에 어떤 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 스키마로 검증과 타입을 한 번에 정의하는 패턴이 인기예요.
- Formik + yup — 오래된 조합. 타입스크립트 지원은 react-hook-form 쪽이 더 좋습니다.
- Server Actions + zod (Next.js) — 폼을 비제어로 두고 서버에서 검증하는 패턴. 클라이언트에서는 거의 코드를 안 짭니다.
이 시리즈는 빌트인만 다루지만, 실제 프로젝트에서는 위 셋 중 하나를 선택해 두고 가는 게 시간 낭비를 줄여 줍니다.
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 뒤로 넘어가면 이미 폼이 제출되어 페이지 이동이 시작될 수 있어요.
마무리
이번 글에서는 다음을 정리했습니다.
- 이벤트 타입은
React.XXXEvent<엘리먼트>형태로 - input 값을 읽을 때는
e.currentTarget.value가 안전 - 인라인 핸들러는 추론에 맡기고, 본문으로 빼면 명시
- 제어 폼은 단일 필드 useState, 여러 필드는 객체 + name 활용
- 비제어 폼은
FormData가 가장 깔끔.string으로 한 번 좁혀 쓰기 - 폼이 커지면 react-hook-form + zod 같은 라이브러리
다음 글(#5 Context와 제네릭 컴포넌트)에서는 createContext 의 타입 인자 패턴, 안전한 useContext 헬퍼, 그리고 제네릭 컴포넌트로 재사용 가능한 컴포넌트를 만드는 법을 다룹니다.