TypeScript + React 実践 #4 イベントとフォームの型付け

#3 hooks の型付け では組み込み hook の型を整理しました。今回はコンポーネントの中で最もよく出会う型付け — イベントオブジェクトとフォーム入力です。

JavaScriptで書くときは e.target.value だけ書けば終わりでしたが、TypeScriptに移すと e が何の型なのかから決めなければなりません。その判断をきれいに下す方法を見ていきましょう。

React イベントオブジェクトの型 — React.XXXEvent #

React の合成イベントオブジェクトはすべて React.SyntheticEvent をベースにしており、イベントの種類と対象要素に応じてより狭い型があります。よく使うのは次の5つくらいです。

イベント
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 #

React + 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 を選ぶのが安全です。

インラインハンドラ — 仮引数の型を書く必要がない場面 #

JSX の中でインラインで使うハンドラは仮引数の型を書かなくても推論されます。親 prop の型(onChange)が子関数のシグネチャを教えてくれるからです。

インライン — 推論に任せる
<input
  onChange={(e) => setQuery(e.target.value)}  // eの型が自動推論される
/>

ハンドラが短ければインラインがすっきりします。長くなったらコンポーネント本文に切り出し、そのときに (e: React.ChangeEvent<HTMLInputElement>) => ... と明示してください。2つのパターンを自由に行き来できれば十分です。

制御フォーム (controlled form) #

最も一般的なパターンから。入力値を state に持ち、入力ごとに 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 が支えてくれる部分が2か所あります。

  1. setName(e.target.value)value は常に stringsetNamestring だけ受け取るので安全。
  2. onSubmite: FormEvent<HTMLFormElement>e.preventDefault() を自動補完してくれる。

複数フィールドを1つのオブジェクトにまとめる #

フィールドが増えるたびに useState を呼ぶのは面倒です。オブジェクト state + ジェネリック 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' で一度絞り込む習慣をつけてください。

非制御フォームの長所はコードがすっきりすることです。短所は入力値に即時に反応する(文字数表示、リアルタイム検証)のが難しいことです。シンプルな送信フォームは非制御、即時フィードバックが必要なフォームは制御というのが一般的なガイドです。

FormEvent.currentTarget.elements — 名前で取り出す #

非制御フォームで FormData の代わりに e.currentTarget.elements.email のように名前で直接取り出すこともできます。ただし TypeScript はフォームの中にどんな input があるか知らないので、次の2ステップが必要です。

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

この方式は型は正確ですが、ボイラープレートが大きいです。フォームが1つや2つなら FormData 側が、複数のフォームで共通の同じ形を使うなら elements 側が向いています。

フォームが大きくなったらライブラリを検討 #

フィールドが5〜6個を超えると、手で型を管理するコストが急速に大きくなります。実務では次のライブラリがよく使われます。

  • react-hook-form + zod — register/handleSubmit が型をほぼ自動で取ってくれます。zod スキーマで検証と型を一度に定義するパターンが人気です。
  • Formik + yup — 古い組み合わせ。TypeScript サポートは react-hook-form のほうが優れています。
  • Server Actions + zod (Next.js) — フォームを非制御にしてサーバー側で検証するパターン。クライアントではほとんどコードを書きません。

このシリーズは組み込みのみを扱いますが、実際のプロジェクトでは上の3つから1つを選んでおくと時間の浪費を減らせます。

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

非同期で使うときの注意点が1つだけ — e.preventDefault()await より先に呼ばなければなりません。await の後ろに移すとすでにフォームが送信されてページ遷移が始まる可能性があります。

まとめ #

今回は次を整理しました。

  • イベント型は React.XXXEvent<要素> の形で
  • input の値を読むときは e.currentTarget.value が安全
  • インラインハンドラは推論に任せ、本文に切り出すと明示
  • 制御フォームは単一フィールドなら useState、複数フィールドはオブジェクト + name 活用
  • 非制御フォームは FormData が最もすっきり。string で一度絞り込んで使う
  • フォームが大きくなったら react-hook-form + zod のようなライブラリ

次回(#5 Context とジェネリックコンポーネント)では createContext の型引数パターン、安全な useContext ヘルパー、そしてジェネリックコンポーネントで再利用可能なコンポーネントを作る方法を扱います。

X