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

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

X