iBetter Books
수정

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.keystring 타입입니다. 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;
}

이벤트 객체를 그대로 전달하면 부모가 입력 요소의 내부 구현을 알아야 합니다. 가공된 값만 전달하면 컴포넌트 사이의 결합도가 낮아집니다. TodoItemonToggle: (id: number) => void 패턴이 이 원칙을 따릅니다.

정리

이 챕터에서 만든 것과 배운 개념을 정리합니다.

  • ChangeEvent<HTMLInputElement>: 텍스트 입력 값 변경
  • FormEvent<HTMLFormElement>: 폼 제출, e.preventDefault() 필수
  • MouseEvent<HTMLButtonElement>: 버튼 클릭
  • KeyboardEvent<HTMLInputElement>: 키보드 입력, e.key로 키 확인
  • Record<TodoStatus, string>: 유니언 키를 갖는 객체 타입
  • 이벤트 객체 대신 가공된 값을 Props로 전달하는 패턴

다음 챕터에서는 JSONPlaceholder API에서 초기 데이터를 불러오고 응답 타입을 정의합니다.