목차
19 장

이벤트와 폼 타이핑

ChangeEvent · FormEvent · KeyboardEvent와 입력 핸들러의 타입, controlled · uncontrolled 폼의 TypeScript 패턴, 그리고 27장 Server Actions FormData의 토대까지 다룹니다.

18장에서 빌트인 hook 들의 타입을 정리했습니다. 본 챕터는 컴포넌트 안에서 가장 자주 만나는 타이핑 — 이벤트 객체와 폼 입력입니다.

6장 (이벤트 핸들링)과 9장 (폼 다루기)에서 본 패턴을 TypeScript 위에 다시 올립니다. 그리고 27장 (Server Actions와 폼)에서 만날 <form action={fn}> + FormData의 새 모델은 본 챕터의 비제어 폼 패턴이 거의 그대로 이어집니다. 본 챕터에서 FormData 다루기에 익숙해지면 27장이 훨씬 가볍게 읽힙니다.

JavaScript로 짤 때는 e.target.value만 적으면 끝이었는데, TypeScript로 옮기면 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 #

리액트 + TypeScript에서 의외로 많이 헷갈리는 부분입니다.

  • 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으로 가는 게 안전합니다.

항목타입의미
e.currentTargetT (타입 인자)핸들러가 걸린 엘리먼트
e.targetEventTarget이벤트가 실제로 시작된 엘리먼트 (위임 시 자식)

인라인 핸들러 — 매개변수 타입을 쓸 필요가 없는 경우 #

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>
  );
}

여기서 TypeScript가 잡아 주는 부분이 두 곳입니다.

  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로 한 번에 읽는 패턴이 가볍고, React 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'으로 한 번 좁히는 습관을 들여야 합니다.

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

27장 Server Actions로의 다리 #

위 비제어 패턴이 그대로 27장 (Server Actions와 폼)의 새 모델로 이어집니다. React 19의 <form action={serverFn}> 안에서는 onSubmit + preventDefault의 한 사이클이 사라지고, 서버 함수가 formData: FormData를 직접 받습니다.

27장에서 만날 모델 미리보기
'use server';

async function sendContactAction(formData: FormData) {
  const email = formData.get('email');
  const message = formData.get('message');

  if (typeof email !== 'string' || typeof message !== 'string') {
    return { error: '잘못된 입력' };
  }

  // 서버에서 DB 저장 / 이메일 발송 등
  await saveContact({ email, message });
}

// 클라이언트
<form action={sendContactAction}>
  <input name="email" type="email" required />
  <textarea name="message" required />
  <button type="submit">보내기</button>
</form>

FormDatatypeof === 'string' 좁히기 패턴이 그대로 살아 있습니다. 본 챕터에서 익힌 비제어 모델이 4부의 새 모델에서도 그대로 통합니다.

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

비제어 폼에서 FormData 대신 e.currentTarget.elements.email처럼 이름으로 직접 꺼낼 수도 있습니다. 다만 TypeScript는 폼 안에 어떤 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 스키마로 검증과 타입을 한 번에 정의하는 패턴이 인기입니다. 21장 (fetch와 API 응답 타이핑)에서 zod를 다시 만납니다.
  • Server Actions + zod (Next.js) — 폼을 비제어로 두고 서버에서 검증하는 패턴. 클라이언트에서는 거의 코드를 안 짭니다. 27장 (Server Actions와 폼)의 모델.

이 책의 본문은 라이브러리 없이 빌트인만 다룹니다. 실제 프로젝트에서는 위 둘 중 하나를 선택해 두고 가는 게 시간 낭비를 줄여 줍니다.

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 뒤로 넘어가면 이미 폼이 제출되어 페이지 이동이 시작될 수 있습니다.

직접 해보기 #

9장에서 만든 가입 폼의 핵심 부분을 TypeScript로 다시 짜고, FormData 기반 비제어 패턴으로 만들어 봅니다.

src/SignupForm.tsx:

src/SignupForm.tsx
import { useState } from 'react';

type SubmittedData = {
  name: string;
  email: string;
  agreed: boolean;
};

function SignupForm() {
  const [submitted, setSubmitted] = useState<SubmittedData | null>(null);
  const [error, setError] = useState<string | null>(null);

  const onSubmit = (e: React.FormEvent<HTMLFormElement>) => {
    e.preventDefault();
    const formData = new FormData(e.currentTarget);
    const name = formData.get('name');
    const email = formData.get('email');
    const agreed = formData.get('agreed');

    if (typeof name !== 'string' || typeof email !== 'string') {
      setError('이름과 이메일을 모두 입력해 주세요.');
      return;
    }
    if (agreed !== 'on') {
      setError('약관에 동의해 주세요.');
      return;
    }

    setError(null);
    setSubmitted({ name, email, agreed: true });
  };

  return (
    <div style={{ padding: '16px', border: '1px solid #ccc', borderRadius: '8px' }}>
      <h2>회원 가입</h2>
      <form onSubmit={onSubmit}>
        <div>
          <label>이름: </label>
          <input name="name" required />
        </div>
        <div>
          <label>이메일: </label>
          <input name="email" type="email" required />
        </div>
        <div>
          <label>
            <input name="agreed" type="checkbox" />
            약관에 동의합니다
          </label>
        </div>
        <button type="submit" style={{ marginTop: '8px' }}>가입</button>
      </form>

      {error && <p style={{ color: 'red', marginTop: '8px' }}>{error}</p>}
      {submitted && (
        <pre style={{ marginTop: '16px', background: '#f4f4f4', padding: '8px' }}>
          {JSON.stringify(submitted, null, 2)}
        </pre>
      )}
    </div>
  );
}

export default SignupForm;

저장하고 동작을 확인해 보세요. 이름 / 이메일 / 체크박스를 모두 채우고 제출하면 결과가 JSON으로 표시됩니다. 잘못 채우면 빨간 에러 메시지가 뜹니다.

체크박스의 경우 FormData.get('agreed')는 체크돼 있으면 'on' 문자열을, 안 돼 있으면 null을 돌려준다는 점이 함정 포인트입니다. 위 코드에서는 agreed !== 'on'으로 좁혀 처리했습니다.

연습문제 #

  1. SignupForm을 controlled 폼 버전으로도 짜 보세요. useState<{ name: string; email: string; agreed: boolean }>로 한 객체에 묶고, 공통 onChange로 받아 처리합니다. 어떤 코드가 더 짧고 어떤 게 더 안전한지 비교해 보세요.
  2. currentTarget vs target 차이 직접 만나기. 부모 <div onClick={...}>에 핸들러를 걸고, 안에 <button>을 두세요. 버튼을 클릭하면 e.target은 button, e.currentTarget은 div 임을 콘솔로 확인합니다. target의 타입이 EventTarget으로 넓게 잡혀 .value 같은 접근이 막히는 것도 관찰합니다.
  3. FormData와 27장 Server Actions 모델 비교. 위 SignupForm의 onSubmit 안 코드를 별도 함수 submitSignup(formData: FormData)로 추출하고, onSubmit 안에서 e.preventDefault() 후 그 함수를 호출하도록 만들어 보세요. 27장에서 만날 Server Action의 시그니처와 거의 동일한 모양이 됩니다.

한 줄 요약: 이벤트 타입은 React.XXXEvent<엘리먼트> 형태로. input 값을 읽을 때는 e.currentTarget.value가 안전. 인라인 핸들러는 추론에 맡기고, 본문으로 빼면 명시. 제어 폼은 단일 필드 useState, 여러 필드는 객체 + name 활용. 비제어 폼은 FormData가 가장 깔끔하고 27장 Server Actions의 모델로 그대로 이어진다. formData.get() 결과는 FormDataEntryValue | null이라 typeof === 'string'으로 한 번 좁혀 쓴다. 폼이 커지면 react-hook-form + zod 같은 라이브러리.

다음 챕터 #

다음 20장 Context와 제네릭 컴포넌트에서는 12장 (useContext)의 JavaScript 패턴을 TypeScript 위로 다시 올립니다. createContext의 타입 인자 패턴, 안전한 useContext 헬퍼, 그리고 List / Select 같은 재사용 컴포넌트를 만드는 제네릭 패턴과 다형 컴포넌트의 as prop까지 다룹니다.

X