メインコンテンツまでスキップ

フォーム

この章では、Reactでのフォームの扱い方を学びます。

この章で学ぶこと

  • 制御コンポーネントの概念と実装
  • 各種フォーム要素の扱い方
  • フォームのバリデーション
  • 送信状態の管理
備考

この章では03章で作成したプロジェクトを使用します。npm run devで開発サーバーを起動し、コードを書きながら学習を進めましょう。

制御コンポーネント

Reactでは、フォームの値をStateで管理する「制御コンポーネント」パターンが一般的です。

制御コンポーネント vs 非制御コンポーネント

フォームの管理には2つのアプローチがあります。

種類値の管理使用場面
制御コンポーネントReact(State)バリデーション、条件付き入力
非制御コンポーネントDOM(ref)シンプルなフォーム、ファイル入力

本章では制御コンポーネントを学びます。非制御コンポーネント(useRef)は12章で解説します。

function ControlledInput() {
// 入力値をStateで管理
const [value, setValue] = useState('')

const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setValue(e.target.value)
}

return (
// 制御コンポーネント: value と onChange をセットで指定
// value={value} → Stateの値を表示
// onChange={handleChange} → 入力時にStateを更新
<input
value={value}
onChange={handleChange}
/>
)
}

基本的なフォーム要素

テキスト入力

function TextInput() {
const [name, setName] = useState('')

const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setName(e.target.value)
}

return (
<div>
<label htmlFor="name">名前:</label>
<input
id="name"
type="text"
value={name}
onChange={handleChange}
/>
</div>
)
}

テキストエリア

function TextAreaInput() {
const [message, setMessage] = useState('')

const handleChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
setMessage(e.target.value)
}

return (
<div>
<label htmlFor="message">メッセージ:</label>
<textarea
id="message"
value={message}
onChange={handleChange}
rows={4}
/>
</div>
)
}

セレクトボックス

function SelectInput() {
const [country, setCountry] = useState('')

const handleChange = (e: React.ChangeEvent<HTMLSelectElement>) => {
setCountry(e.target.value)
}

return (
<div>
<label htmlFor="country">国:</label>
<select
id="country"
value={country}
onChange={handleChange}
>
<option value="">選択してください</option>
<option value="jp">日本</option>
<option value="us">アメリカ</option>
<option value="uk">イギリス</option>
</select>
</div>
)
}

チェックボックス

function CheckboxInput() {
const [agreed, setAgreed] = useState(false)

const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
// チェックボックスは value ではなく checked を使う
setAgreed(e.target.checked)
}

return (
<div>
<label>
<input
type="checkbox"
checked={agreed} // value ではなく checked で制御
onChange={handleChange}
/>
利用規約に同意する
</label>
</div>
)
}

ラジオボタン

function RadioInput() {
const [gender, setGender] = useState('')

const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setGender(e.target.value)
}

return (
<div>
<label>
<input
type="radio"
name="gender"
value="male"
// checked={gender === 'male'} で選択状態を判定
// Stateの値と一致するラジオボタンがチェックされる
checked={gender === 'male'}
onChange={handleChange}
/>
男性
</label>
<label>
<input
type="radio"
name="gender"
value="female"
checked={gender === 'female'}
onChange={handleChange}
/>
女性
</label>
</div>
)
}

複数のフォーム要素を管理

オブジェクトで管理

// ブラウザ組み込みの FormData 型と名前が被らないようにする
type ContactFormData = {
name: string
email: string
message: string
}

function ContactForm() {
// 複数の入力値をオブジェクトで一括管理
const [formData, setFormData] = useState<ContactFormData>({
name: '',
email: '',
message: ''
})

// input と textarea 両方に対応するため union 型を使用
const handleChange = (
e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>
) => {
// e.target.name で入力欄の name 属性を取得
const { name, value } = e.target
// [name]: value は「動的プロパティ名」
// name="email" なら { ...prev, email: value } と同じ意味になる
setFormData(prev => ({ ...prev, [name]: value }))
}

const handleSubmit = (e: React.FormEvent) => {
e.preventDefault()
console.log(formData)
}

return (
<form onSubmit={handleSubmit}>
<div>
<label htmlFor="name">名前:</label>
{/* name="name" は formData のプロパティ名と一致させる */}
<input
id="name"
name="name"
value={formData.name}
onChange={handleChange}
/>
</div>
<div>
<label htmlFor="email">メール:</label>
<input
id="email"
name="email"
type="email"
value={formData.email}
onChange={handleChange}
/>
</div>
<div>
<label htmlFor="message">メッセージ:</label>
<textarea
id="message"
name="message"
value={formData.message}
onChange={handleChange}
/>
</div>
<button type="submit">送信</button>
</form>
)
}

試してみよう: プロフィールフォーム

名前、メール、自己紹介を入力できるフォームを作成し、送信ボタンを押すとコンソールに内容を表示してみましょう。

ヒント
  1. ProfileData型を定義し、オブジェクトのStateで管理
  2. 各入力にname属性を設定
  3. handleChangeで一括更新
  4. handleSubmite.preventDefault()を忘れずに
回答と解説
import { useState } from 'react'
import './App.css'

type ProfileData = {
name: string
email: string
bio: string
}

function ProfileForm() {
const [formData, setFormData] = useState<ProfileData>({
name: '',
email: '',
bio: ''
})

const handleChange = (
e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>
) => {
const { name, value } = e.target
setFormData(prev => ({ ...prev, [name]: value }))
}

const handleSubmit = (e: React.FormEvent) => {
e.preventDefault()
console.log('送信データ:', formData)
}

return (
<form onSubmit={handleSubmit}>
<div>
<label htmlFor="name">名前:</label>
<input
id="name"
name="name"
value={formData.name}
onChange={handleChange}
/>
</div>
<div>
<label htmlFor="email">メール:</label>
<input
id="email"
name="email"
type="email"
value={formData.email}
onChange={handleChange}
/>
</div>
<div>
<label htmlFor="bio">自己紹介:</label>
<textarea
id="bio"
name="bio"
value={formData.bio}
onChange={handleChange}
rows={4}
/>
</div>
<button type="submit">送信</button>
</form>
)
}

export default function App() {
return <ProfileForm />
}

name属性を各入力に設定し、[name]: valueの動的プロパティで一括更新しています。これにより、入力フィールドが増えてもhandleChangeを使い回せます。

バリデーション

シンプルなバリデーション

type FormErrors = {
name?: string
email?: string
}

function ValidatedForm() {
const [name, setName] = useState('')
const [email, setEmail] = useState('')
const [errors, setErrors] = useState<FormErrors>({})

const handleNameChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setName(e.target.value)
}

const handleEmailChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setEmail(e.target.value)
}

// バリデーション関数: 入力値をチェックしてエラーを設定
// 戻り値 true → バリデーション成功、false → 失敗
const validate = (): boolean => {
const newErrors: FormErrors = {}

// trim() で前後の空白を除去してから判定
if (!name.trim()) {
newErrors.name = '名前は必須です'
}

if (!email.trim()) {
newErrors.email = 'メールは必須です'
} else if (!/\S+@\S+\.\S+/.test(email)) {
// 正規表現でメール形式をチェック
newErrors.email = '有効なメールアドレスを入力してください'
}

setErrors(newErrors)
// エラーがなければ(オブジェクトのキーが0個なら)true を返す
return Object.keys(newErrors).length === 0
}

const handleSubmit = (e: React.FormEvent) => {
e.preventDefault()
if (validate()) {
console.log('送信:', { name, email })
}
}

return (
<form onSubmit={handleSubmit}>
<div>
<input
value={name}
onChange={handleNameChange}
placeholder="名前"
/>
{errors.name && <span className="error">{errors.name}</span>}
</div>
<div>
<input
type="email"
value={email}
onChange={handleEmailChange}
placeholder="メール"
/>
{errors.email && <span className="error">{errors.email}</span>}
</div>
<button type="submit">送信</button>
</form>
)
}

リアルタイムバリデーション

function RealtimeValidation() {
const [email, setEmail] = useState('')
// touched: ユーザーが入力欄にフォーカスしたことがあるかどうか
// 初回表示時にエラーを出さないために使う
const [touched, setTouched] = useState(false)

const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setEmail(e.target.value)
}

// onBlur: 入力欄からフォーカスが外れたときに発火
const handleBlur = () => {
setTouched(true)
}

const isValid = /\S+@\S+\.\S+/.test(email)
// 3つの条件すべてを満たすときだけエラーを表示
// 1. touched: フォーカスしたことがある
// 2. !isValid: 形式が不正
// 3. email.length > 0: 何か入力されている
const showError = touched && !isValid && email.length > 0

return (
<div>
<input
type="email"
value={email}
onChange={handleChange}
onBlur={handleBlur}
placeholder="メールアドレス"
/>
{showError && (
<span className="error">有効なメールアドレスを入力してください</span>
)}
</div>
)
}

試してみよう: バリデーション追加

プロフィールフォームに、名前が空の場合に「名前は必須です」というエラーメッセージを表示するバリデーションを追加してみましょう。

ヒント
  1. errorsというStateを追加
  2. validate関数で名前が空かチェック
  3. エラーがあればStateに保存して表示
  4. 入力時にエラーをクリアするとUXが向上
回答と解説
import { useState } from 'react'
import './App.css'

type ProfileData = {
name: string
email: string
bio: string
}

type FormErrors = {
name?: string
}

function ProfileFormWithValidation() {
const [formData, setFormData] = useState<ProfileData>({
name: '',
email: '',
bio: ''
})
const [errors, setErrors] = useState<FormErrors>({})

const handleChange = (
e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>
) => {
const { name, value } = e.target
setFormData(prev => ({ ...prev, [name]: value }))
// 入力時にエラーをクリア
if (errors[name as keyof FormErrors]) {
setErrors(prev => ({ ...prev, [name]: undefined }))
}
}

const validate = (): boolean => {
const newErrors: FormErrors = {}
if (!formData.name.trim()) {
newErrors.name = '名前は必須です'
}
setErrors(newErrors)
return Object.keys(newErrors).length === 0
}

const handleSubmit = (e: React.FormEvent) => {
e.preventDefault()
if (validate()) {
console.log('送信データ:', formData)
}
}

return (
<form onSubmit={handleSubmit}>
<div>
<label htmlFor="name">名前:</label>
<input
id="name"
name="name"
value={formData.name}
onChange={handleChange}
/>
{errors.name && <span style={{ color: 'red' }}>{errors.name}</span>}
</div>
{/* 他のフィールド... */}
<button type="submit">送信</button>
</form>
)
}

export default function App() {
return <ProfileFormWithValidation />
}

validate関数で必須チェックを行い、エラーがあればStateに保存して表示します。入力時にエラーをクリアすることで、ユーザー体験が向上します。

アクセシビリティを考慮したフォーム

フォームはユーザーとのインタラクションが多いぶん、アクセシビリティ(a11y)の配慮が品質に直結します。バリデーション付きフォームの実装では、以下の3点を最低限押さえてください。

  • <label> と入力を htmlFor / id で関連付ける: スクリーンリーダーがラベルを読み上げられるようになり、ラベルをクリックしたときに入力にフォーカスが移るようになります
  • エラーメッセージは aria-describedby で入力と関連付ける: スクリーンリーダーがエラー内容を読み上げます
  • 入力が無効なときは aria-invalid="true": スクリーンリーダーが「無効な入力」と通知します
function AccessibleInput() {
const [email, setEmail] = useState('')
const [error, setError] = useState<string | null>(null)

return (
<div>
<label htmlFor="email">メールアドレス</label>
<input
id="email"
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
aria-invalid={error !== null}
aria-describedby={error ? 'email-error' : undefined}
required
/>
{error && (
<p id="email-error" role="alert" style={{ color: 'red' }}>
{error}
</p>
)}
</div>
)
}
備考

role="alert" を付けると、メッセージが表示された瞬間にスクリーンリーダーが読み上げます。バリデーションエラーの通知に適しています。必須マークは視覚的な * だけでなく、required 属性と <label> 内のテキスト(例: 「(必須)」)の両方で示すと親切です。

送信状態の管理

function SubmitForm() {
const [name, setName] = useState('')
const [isSubmitting, setIsSubmitting] = useState(false)
const [isSuccess, setIsSuccess] = useState(false)

const handleNameChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setName(e.target.value)
}

// async/await で非同期処理を扱う
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
setIsSubmitting(true) // 送信中フラグをON

try {
const res = await fetch('/api/submit', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name })
})
// fetch は 4xx/5xx のサーバーエラーでは reject しないため、ok を確認する
if (!res.ok) {
throw new Error(`送信に失敗しました (${res.status})`)
}
setIsSuccess(true) // 成功フラグをON
setName('') // 入力欄をクリア
} catch (error) {
console.error('送信エラー:', error)
} finally {
// finally: 成功・失敗どちらでも必ず実行される
setIsSubmitting(false) // 送信中フラグをOFF
}
}

if (isSuccess) {
return <p>送信が完了しました!</p>
}

return (
<form onSubmit={handleSubmit}>
<input
value={name}
onChange={handleNameChange}
disabled={isSubmitting}
/>
<button type="submit" disabled={isSubmitting}>
{isSubmitting ? '送信中...' : '送信'}
</button>
</form>
)
}

試してみよう: 送信状態の管理

送信ボタンを押すと2秒間「送信中...」と表示し、その後「送信完了!」と表示するフォームを作ってみましょう。

ヒント
  1. isSubmittingisSuccessのStateを追加
  2. setTimeoutで2秒後に状態を切り替え
  3. 送信中は入力とボタンをdisabled
  4. 完了後は「もう一度送信」ボタンを表示
回答と解説
import { useState } from 'react'
import './App.css'

function SubmitStateForm() {
const [name, setName] = useState('')
const [isSubmitting, setIsSubmitting] = useState(false)
const [isSuccess, setIsSuccess] = useState(false)

const handleNameChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setName(e.target.value)
}

const handleReset = () => {
setIsSuccess(false)
setName('')
}

const handleSubmit = (e: React.FormEvent) => {
e.preventDefault()
setIsSubmitting(true)

// 2秒後に完了を表示
setTimeout(() => {
setIsSubmitting(false)
setIsSuccess(true)
}, 2000)
}

if (isSuccess) {
return (
<div>
<p>送信完了!</p>
<button onClick={handleReset}>
もう一度送信
</button>
</div>
)
}

return (
<form onSubmit={handleSubmit}>
<input
value={name}
onChange={handleNameChange}
placeholder="名前"
disabled={isSubmitting}
/>
<button type="submit" disabled={isSubmitting}>
{isSubmitting ? '送信中...' : '送信'}
</button>
</form>
)
}

export default function App() {
return <SubmitStateForm />
}

isSubmittingで送信中の状態を、isSuccessで完了状態を管理しています。送信中は入力とボタンをdisabledにして、重複送信を防いでいます。

React 19 の Actions パターン

React 19 では、フォーム送信などの非同期処理を簡潔に書くための「Actions」が導入されました。useActionState / useFormStatus / useOptimistic を使うと、送信中フラグやエラー状態の管理を自分で書かなくても済むようになります。

useActionState

送信処理と状態管理をひとまとめに扱えます。送信関数の返り値がそのまま state として保持されます。

import { useActionState } from 'react'

type FormState = {
message: string
error: string | null
}

async function submitAction(_prevState: FormState, formData: FormData): Promise<FormState> {
const name = formData.get('name') as string

if (!name) {
return { message: '', error: '名前を入力してください' }
}

await fetch('/api/submit', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name }),
})

return { message: `${name}さん、送信が完了しました`, error: null }
}

function ActionForm() {
const [state, formAction, isPending] = useActionState(submitAction, {
message: '',
error: null,
})

return (
<form action={formAction}>
<input name="name" placeholder="名前" disabled={isPending} />
<button type="submit" disabled={isPending}>
{isPending ? '送信中...' : '送信'}
</button>
{state.error && <p style={{ color: 'red' }}>{state.error}</p>}
{state.message && <p>{state.message}</p>}
</form>
)
}

ポイントは3つです。

  • formaction 属性に関数を渡すだけで、送信時に呼び出される
  • isPending が自動で管理されるため、自分で isSubmitting を管理する必要がない
  • 送信関数は「前の state」と FormData を受け取り、新しい state を返すだけでよい

useFormStatus

送信ボタンなど、フォームの子コンポーネントで送信状態を参照したい場合に使います。

import { useFormStatus } from 'react-dom'

function SubmitButton() {
const { pending } = useFormStatus()
return (
<button type="submit" disabled={pending}>
{pending ? '送信中...' : '送信'}
</button>
)
}

useFormStatus は、呼び出し元の直近の親 <form> の状態を読み取ります。フォーム全体にロジックを書かなくても、ボタンコンポーネント側で送信中表示を完結させられます。

useOptimistic

サーバー応答を待たずに UI を先に更新したい場合に使います。「いいね」ボタンやチャット送信など、即座にフィードバックを返したい UI に適しています。

import { useOptimistic, useState } from 'react'

function LikeButton({ initialCount }: { initialCount: number }) {
const [count, setCount] = useState(initialCount)
const [optimisticCount, addOptimistic] = useOptimistic(count, (current: number) => current + 1)

async function handleLike() {
addOptimistic(null) // 先にUIを更新
const res = await fetch('/api/like', { method: 'POST' })
const data = await res.json()
setCount(data.count) // サーバーの確定値で上書き
}

return (
<form action={handleLike}>
<button type="submit">いいね ({optimisticCount})</button>
</form>
)
}
備考

useOptimistic で設定された値は、実際の state が更新されるか、アクションが失敗した時点で自動的に元の値にロールバックされます。手動で巻き戻す処理を書く必要はありません。

従来パターンとの使い分け

状況推奨パターン
シンプルな送信処理useActionState
送信中の見た目だけ子で切り替えるuseFormStatus
楽観的UI更新が必要useOptimistic
複雑なバリデーションが必要React Hook Form + Zod
非制御コンポーネント(ファイル入力等)useRef + 従来パターン

Actions は単純なケースでコード量を大きく減らせますが、フィールドごとに細かくバリデーションしたい場合や、動的なフィールドを扱う複雑なフォームでは、従来の controlled + state パターンや専用ライブラリのほうが向いています。

フォームライブラリ

複雑なフォームでは、専用ライブラリの使用を検討してください。

  • React Hook Form v7: 軽量で高パフォーマンス、非制御コンポーネント主体
  • TanStack Form: TanStack Query と同じチームが開発。型安全性と柔軟性に優れる新型選択肢
  • Formik: 機能豊富で実績あり(メンテナンスが活発ではないため新規採用は要検討)
  • Zod + React Hook Form: スキーマベースのバリデーション(次章で詳説)
備考

本番アプリケーションでは、フォームライブラリの使用を推奨します。バリデーション、エラーハンドリング、パフォーマンス最適化が容易になります。

備考

Zod のスキーマ定義・z.infer による型推論・React Hook Form 連携・v4 の注意点は、次章「Zod でスキーマバリデーション」で実装例とともに詳しく扱います。

まとめ

  • 制御コンポーネントでフォームの値をStateで管理
  • 複数の入力はオブジェクトでまとめて管理
  • バリデーションは送信時またはリアルタイムで実行
  • 送信状態(loading、success、error)を適切に管理
  • 複雑なフォームはライブラリの使用を検討

次の章では、Zod を使ってフォームと外部データを型安全にバリデーションします。

次に読む

Zod でスキーマバリデーション へ進みます。スキーマ定義・型推論・React Hook Form 連携・API レスポンスの実行時検証を学びます。