Ch 04. 이벤트 핸들러 타이핑
React에서 이벤트를 다루다 보면 타입 에러를 자주 만납니다. e.target.value에서 "Property 'value' does not exist on type 'EventTarget'"이라는 에러, 한 번쯤 보셨을 겁니다.
이 챕터에서는 React의 이벤트 타입 체계를 이해하고, 할 일 추가 폼과 필터 버튼을 만들면서 실전 패턴을 익힙니다.
React의 합성 이벤트
브라우저마다 이벤트 객체의 구현이 조금씩 다릅니다. React는 이 차이를 추상화한 "합성 이벤트(SyntheticEvent)"를 사용합니다. TypeScript에서는 React.SyntheticEvent<T, E> 타입으로 표현됩니다.
자주 사용하는 이벤트 타입들을 먼저 정리합니다.
| 이벤트 | 타입 | 주로 쓰는 곳 |
|---|---|---|
| onChange | React.ChangeEvent<HTMLInputElement> |
input, textarea |
| onChange (select) | React.ChangeEvent<HTMLSelectElement> |
select |
| onSubmit | React.FormEvent<HTMLFormElement> |
form |
| onClick | React.MouseEvent<HTMLButtonElement> |
button |
| onKeyDown | React.KeyboardEvent<HTMLInputElement> |
input |
| onFocus | React.FocusEvent<HTMLInputElement> |
input |
제네릭 타입 인수에는 이벤트가 발생하는 HTML 요소의 타입을 넣습니다. 이렇게 하면 e.target이 해당 요소의 타입으로 좁혀지고, e.target.value 같은 속성에 안전하게 접근할 수 있습니다.
ChangeEvent와 FormEvent 비교
// 파일: 예시 (타입 설명용)
// ChangeEvent: 값이 변경될 때
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const value = e.target.value; // string
const checked = e.target.checked; // boolean (type="checkbox")
};
// FormEvent: 폼이 제출될 때
const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault(); // 기본 동작(페이지 새로고침) 방지
};
FormEvent에는 value가 없습니다. ChangeEvent에는 preventDefault도 있지만, 폼 제출은 FormEvent를 써야 의미가 명확합니다.
TodoForm 컴포넌트 만들기
// 새 파일: src/components/TodoForm.tsx
import { useState, type FormEvent, type ChangeEvent } from 'react';
import { useTodoContext } from '../context/TodoContext';
function TodoForm() {
const { dispatch } = useTodoContext();
const [inputValue, setInputValue] = useState('');
const [error, setError] = useState<string | null>(null);
const handleChange = (e: ChangeEvent<HTMLInputElement>) => {
setInputValue(e.target.value);
if (error) setError(null);
};
const handleSubmit = (e: FormEvent<HTMLFormElement>) => {
e.preventDefault();
const trimmed = inputValue.trim();
if (!trimmed) {
setError('할 일 내용을 입력해주세요.');
return;
}
if (trimmed.length > 100) {
setError('100자 이내로 입력해주세요.');
return;
}
dispatch({ type: 'ADD_TODO', payload: trimmed });
setInputValue('');
};
return (
<form className="todo-form" onSubmit={handleSubmit}>
<div className="input-group">
<input
type="text"
value={inputValue}
onChange={handleChange}
placeholder="새로운 할 일을 입력하세요"
aria-label="새로운 할 일"
aria-invalid={error !== null}
aria-describedby={error ? 'input-error' : undefined}
className={error ? 'input-error' : ''}
maxLength={100}
/>
<button type="submit">추가</button>
</div>
{error && (
<p id="input-error" className="error-message" role="alert">
{error}
</p>
)}
</form>
);
}
export default TodoForm;
error 상태의 타입을 string | null로 정의했습니다. 에러가 없을 때는 null, 있을 때는 에러 메시지 문자열입니다. boolean보다 명확한 이유는 에러 메시지 내용 자체를 상태로 가질 수 있기 때문입니다.
aria-describedby={error ? 'input-error' : undefined}에서 undefined를 쓰면 React가 해당 속성을 DOM에서 제거합니다. null이 아닌 undefined를 사용하는 이유입니다.
필터 버튼 컴포넌트 만들기
// 새 파일: src/components/TodoFilter.tsx
import type { MouseEvent } from 'react';
import type { TodoStatus } from '../types/todo';
import { useTodoContext } from '../context/TodoContext';
const FILTER_LABELS: Record<TodoStatus, string> = {
all: '전체',
active: '미완료',
completed: '완료',
};
function TodoFilter() {
const { state, dispatch } = useTodoContext();
const handleFilterClick = (
e: MouseEvent<HTMLButtonElement>,
filter: TodoStatus
) => {
e.preventDefault();
dispatch({ type: 'SET_FILTER', payload: filter });
};
return (
<div className="todo-filter" role="group" aria-label="할 일 필터">
{(Object.keys(FILTER_LABELS) as TodoStatus[]).map((filter) => (
<button
key={filter}
className={`filter-btn ${state.filter === filter ? 'active' : ''}`}
onClick={(e) => handleFilterClick(e, filter)}
aria-pressed={state.filter === filter}
>
{FILTER_LABELS[filter]}
</button>
))}
</div>
);
}
export default TodoFilter;
Record<TodoStatus, string>은 TodoStatus의 모든 멤버를 키로, string을 값으로 갖는 객체 타입입니다. 'all', 'active', 'completed' 세 키가 모두 있어야 합니다. 하나라도 빠지면 타입 에러가 납니다.
Object.keys(FILTER_LABELS) as TodoStatus[]에서 타입 단언을 쓰는 이유가 있습니다. Object.keys()의 반환 타입은 항상 string[]입니다. 객체의 키 타입 정보를 보존하지 않습니다. Record<TodoStatus, string>에서 키가 TodoStatus임을 알지만, Object.keys는 그 정보를 돌려주지 않습니다. 이 경우 타입 단언이 안전합니다.
키보드 이벤트 다루기
엔터키 대신 Escape 키로 입력을 취소하는 기능을 추가합니다.
// 수정: src/components/TodoForm.tsx
import {
useState,
type FormEvent,
type ChangeEvent,
type KeyboardEvent,
} from 'react';
import { useTodoContext } from '../context/TodoContext';
function TodoForm() {
const { dispatch } = useTodoContext();
const [inputValue, setInputValue] = useState('');
const [error, setError] = useState<string | null>(null);
const handleChange = (e: ChangeEvent<HTMLInputElement>) => {
setInputValue(e.target.value);
if (error) setError(null);
};
const handleKeyDown = (e: KeyboardEvent<HTMLInputElement>) => {
if (e.key === 'Escape') {
setInputValue('');
setError(null);
}
};
const handleSubmit = (e: FormEvent<HTMLFormElement>) => {
e.preventDefault();
const trimmed = inputValue.trim();
if (!trimmed) {
setError('할 일 내용을 입력해주세요.');
return;
}
if (trimmed.length > 100) {
setError('100자 이내로 입력해주세요.');
return;
}
dispatch({ type: 'ADD_TODO', payload: trimmed });
setInputValue('');
};
return (
<form className="todo-form" onSubmit={handleSubmit}>
<div className="input-group">
<input
type="text"
value={inputValue}
onChange={handleChange}
onKeyDown={handleKeyDown}
placeholder="새로운 할 일을 입력하세요"
aria-label="새로운 할 일"
aria-invalid={error !== null}
aria-describedby={error ? 'input-error' : undefined}
className={error ? 'input-error' : ''}
maxLength={100}
/>
<button type="submit">추가</button>
</div>
{error && (
<p id="input-error" className="error-message" role="alert">
{error}
</p>
)}
</form>
);
}
export default TodoForm;
e.key는 string 타입입니다. TypeScript는 'Escape', 'Enter' 같은 구체적인 값을 알지 못하므로, 문자열 비교로 처리합니다. 오타가 나도 런타임까지 잡히지 않으니 주의가 필요합니다.
App.tsx에 폼과 필터 추가
// 수정: src/App.tsx
import { TodoProvider } from './context/TodoContext';
import TodoForm from './components/TodoForm';
import TodoFilter from './components/TodoFilter';
import TodoList from './components/TodoList';
import './App.css';
function AppContent() {
return (
<div className="app">
<h1>할 일 앱</h1>
<TodoForm />
<TodoFilter />
<TodoList />
</div>
);
}
function App() {
return (
<TodoProvider>
<AppContent />
</TodoProvider>
);
}
export default App;
이제 할 일을 추가하고, 완료 토글, 삭제, 필터링까지 모두 동작합니다.
이벤트 핸들러를 Props로 전달할 때
부모가 이벤트 핸들러를 자식에게 Props로 전달할 때의 타입 패턴입니다.
// 파일: 예시 (타입 패턴 설명용)
interface SearchBoxProps {
// 이벤트 객체 전달
onChange: (e: React.ChangeEvent<HTMLInputElement>) => void;
// 가공된 값만 전달 (더 권장)
onSearch: (query: string) => void;
}
이벤트 객체를 그대로 전달하면 부모가 입력 요소의 내부 구현을 알아야 합니다. 가공된 값만 전달하면 컴포넌트 사이의 결합도가 낮아집니다. TodoItem의 onToggle: (id: number) => void 패턴이 이 원칙을 따릅니다.
정리
이 챕터에서 만든 것과 배운 개념을 정리합니다.
ChangeEvent<HTMLInputElement>: 텍스트 입력 값 변경FormEvent<HTMLFormElement>: 폼 제출,e.preventDefault()필수MouseEvent<HTMLButtonElement>: 버튼 클릭KeyboardEvent<HTMLInputElement>: 키보드 입력,e.key로 키 확인Record<TodoStatus, string>: 유니언 키를 갖는 객체 타입- 이벤트 객체 대신 가공된 값을 Props로 전달하는 패턴
다음 챕터에서는 JSONPlaceholder API에서 초기 데이터를 불러오고 응답 타입을 정의합니다.