타입스크립트 + React 실전 #4 이벤트와 폼 타이핑

14 분 소요

#3 hooks 타이핑 에서 빌트인 hook들의 타입을 정리했습니다. 이번 글은 컴포넌트 안에서 가장 자주 만나는 타이핑 — 이벤트 객체와 폼 입력입니다.

자바스크립트로 짤 때는 e.target.value 만 적으면 끝났는데, 타입스크립트로 옮기면 e 가 무슨 타입인지부터 정해야 합니다. 그 결정을 깔끔하게 내리는 법을 보겠습니다.

React 이벤트 객체의 타입 — React.XXXEvent

리액트의 합성 이벤트 객체는 모두 React.SyntheticEvent 를 베이스로 하고, 이벤트 종류와 대상 엘리먼트에 따라 더 좁은 타입이 있습니다. 자주 쓰는 건 다음 다섯 가지 정도입니다.

이벤트타입
onClickReact.MouseEvent<HTMLButtonElement>
onChange (input)React.ChangeEvent<HTMLInputElement>
onSubmit (form)React.FormEvent<HTMLFormElement>
onKeyDownReact.KeyboardEvent<HTMLInputElement>
onFocus / onBlurReact.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 가 정답이에요.

currentTarget을 쓰자
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를 부릅니다.

제어 input — 단일 필드
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>
  );
}

여기서 타입스크립트가 잡아 주는 부분이 두 곳입니다.

  1. setName(e.target.value) 에서 value 는 항상 string. setNamestring 만 받으니 안전.
  2. onSubmite: 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 와도 자연스럽게 어울립니다.

비제어 — FormData로 한 번에
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 입니다. FormDataEntryValuestring | File이라 — 텍스트 입력값을 다룰 때는 typeof === 'string' 으로 한 번 좁히는 습관을 들여야 해요.

비제어 폼의 장점은 코드가 깔끔하다는 점입니다. 단점은 입력값에 즉각 반응(글자 수 표시, 실시간 검증)하기 어렵다는 점이에요. 간단한 제출 폼은 비제어, 즉각 피드백이 필요한 폼은 제어가 일반적인 가이드입니다.

FormEvent.currentTarget.elements — 이름으로 꺼내기

비제어 폼에서 FormData 대신 e.currentTarget.elements.email 처럼 이름으로 직접 꺼낼 수도 있습니다. 다만 타입스크립트는 폼 안에 어떤 input이 있는지 모르기 때문에 다음 두 단계가 필요해요.

elements로 직접 꺼내기
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은 그 두 형태를 모두 받게 정의되어 있어요.

async onSubmit
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 헬퍼, 그리고 제네릭 컴포넌트로 재사용 가능한 컴포넌트를 만드는 법을 다룹니다.