イベントとフォームのタイピング
ChangeEvent · FormEvent · KeyboardEvent と入力ハンドラの型、controlled · uncontrolled フォームの TypeScript パターン、そして 27章 Server Actions の FormData の土台まで扱います。
18章でビルトインフックの型を整理しました。本章はコンポーネントの中でもっとも頻繁に出会うタイピング — イベントオブジェクトとフォーム入力 です。
6章(イベントハンドリング)と 9章(フォーム処理)で見たパターンを TypeScript の上に載せ直します。さらに 27章(Server Actions とフォーム)で出会う <form action={fn}> + FormData の新モデルは、本章の非制御フォームパターンがほぼそのまま続きます。本章で FormData の扱いに慣れておくと、27章がはるかに軽く読めます。
JavaScript で書くときは e.target.value と書けば終わりでしたが、TypeScript に移ると e が何の型かをまず決める必要があります。その決定をきれいに下す方法を見ていきます。
React イベントオブジェクトの型 — React.XXXEvent
#
React の合成イベントオブジェクトはすべて React.SyntheticEvent をベースに、イベント種別と対象エレメントに応じてより狭い型があります。よく使うのは次の 5 つほどです。
| イベント | 型 |
|---|---|
| onClick | React.MouseEvent<HTMLButtonElement> |
| onChange (input) | React.ChangeEvent<HTMLInputElement> |
| onSubmit (form) | React.FormEvent<HTMLFormElement> |
| onKeyDown | React.KeyboardEvent<HTMLInputElement> |
| onFocus / onBlur | React.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 が正解です。
const onChange = (e: React.ChangeEvent<HTMLInputElement>) => {
// 両方とも動作はするが
console.log(e.target.value); // EventTarget — ナローイングはされるが意味が曖昧
console.log(e.currentTarget.value); // HTMLInputElement — より安全
};onChange の場合は target もナローイングされますが、ボタンやリストのように委譲パターンを使うとき は currentTarget だけが正解です。迷ったときは常に currentTarget で行くのが安全です。
| 項目 | 型 | 意味 |
|---|---|---|
e.currentTarget | T(型引数) | ハンドラがかかっているエレメント |
e.target | EventTarget | イベントが実際に始まったエレメント(委譲時に子) |
インラインハンドラ — 引数の型を書く必要がない場合 #
JSX の中でインラインに書くハンドラは 引数の型を書かなくても 推論されます。親 prop 型(onChange)が子関数のシグネチャを教えてくれるからです。
<input
onChange={(e) => setQuery(e.target.value)} // e の型が自動推論される
/>ハンドラが短ければインラインがきれいです。長くなるとコンポーネント本体に出して、そのとき (e: React.ChangeEvent<HTMLInputElement>) => ... で明示してください。2 つのパターンを自由に行き来できれば十分です。
制御フォーム(controlled form) #
もっともよくあるパターンから。入力値を状態にして、入力のたびに setter を呼びます。
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 つあります。
setName(e.target.value)でvalueは常にstring。setNameもstringだけ受け取るので安全。onSubmitのe: 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 とも自然に組み合わさります。
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 です。FormDataEntryValue は string | File なので、テキスト入力値を扱うときは typeof === 'string' で一度ナローイングする習慣を付けるべきです。
非制御フォームの長所はコードがきれいになる点です。短所は入力値に即時反応(文字数表示、リアルタイム検証)するのが難しい点です。単純な送信フォームは非制御、即時フィードバックが必要なフォームは制御 が一般的なガイドです。
27章 Server Actions への橋渡し #
上の非制御パターンがそのまま 27章(Server Actions とフォーム)の新モデルにつながります。React 19 の <form action={serverFn}> の中では onSubmit + preventDefault の 1 サイクルが消え、サーバー関数が formData: FormData を直接受け取ります。
'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>FormData と typeof === 'string' ナローイングのパターンがそのまま生きています。本章で身につけた非制御モデルが 4部の新モデルでもそのまま通用します。
FormEvent.currentTarget.elements — 名前で取り出す
#
非制御フォームで FormData の代わりに e.currentTarget.elements.email のように名前で直接取り出すこともできます。ただし TypeScript はフォームの中にどんな input があるか知らないので、次の 2 段階が必要です。
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 スキーマで検証と型を一度に定義するパターンが人気です。21章(fetch と API レスポンスのタイピング)で zod に再び出会います。
- Server Actions + zod(Next.js) — フォームを非制御にしておきサーバーで検証するパターン。クライアントではほぼコードを書きません。27章(Server Actions とフォーム)のモデル。
本書の本文はライブラリなしでビルトインだけを扱います。実際のプロジェクトでは上のどちらかを選んでおく方が時間の浪費を減らせます。
Submit ハンドラの戻り値型 #
Submit ハンドラを非同期で書くと戻り値型が Promise<void> になります。JSX の onSubmit prop はその両方の形を受け取れるように定義されています。
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 の後に回すと、すでにフォームが送信されページ遷移が始まっている可能性があります。
自分でやってみる #
9章で作った登録フォームの主要部分を TypeScript で書き直し、FormData ベースの非制御パターンで作ってみましょう。
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' でナローイングして処理しました。
練習問題 #
- 上の
SignupFormを controlled フォーム版でも書いてみてください。useState<{ name: string; email: string; agreed: boolean }>で一つのオブジェクトに束ね、共通のonChangeで受けて処理します。どちらのコードがより短く、どちらがより安全かを比較してみてください。 currentTargetvstargetの違いを直接体験します。親<div onClick={...}>にハンドラをかけ、中に<button>を置いてください。ボタンをクリックするとe.targetは button、e.currentTargetは div であることをコンソールで確認します。targetの型がEventTargetとして広く捕まえられ、.valueのようなアクセスが塞がれるのも観察します。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 までを扱います。