フォーム処理
フォームはユーザー入力の要。バリデーション、エラー表示、送信処理を適切に実装する方法を学びます。
基本的なフォーム(Controlled Component)
"use client";
import { useState } from "react";
export function BasicForm() {
const [formData, setFormData] = useState({
title: "",
content: "",
});
const [isSubmitting, setIsSubmitting] = useState(false);
const handleChange = (
e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>
) => {
const { name, value } = e.target;
setFormData((prev) => ({ ...prev, [name]: value }));
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setIsSubmitting(true);
try {
const res = await fetch("/api/posts", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(formData),
});
if (res.ok) {
alert("投稿しました!");
setFormData({ title: "", content: "" });
}
} catch (error) {
alert("エラーが発生しました");
} finally {
setIsSubmitting(false);
}
};
return (
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label className="block font-bold mb-1">タイトル</label>
<input
type="text"
name="title"
value={formData.title}
onChange={handleChange}
className="w-full border rounded px-3 py-2"
required
/>
</div>
<div>
<label className="block font-bold mb-1">本文</label>
<textarea
name="content"
value={formData.content}
onChange={handleChange}
className="w-full border rounded px-3 py-2 h-32"
required
/>
</div>
<button
type="submit"
disabled={isSubmitting}
className="bg-blue-600 text-white px-4 py-2 rounded disabled:opacity-50"
>
{isSubmitting ? "送信中..." : "投稿する"}
</button>
</form>
);
}
バリデーション
"use client";
import { useState } from "react";
type Errors = {
title?: string;
content?: string;
};
export function ValidatedForm() {
const [formData, setFormData] = useState({ title: "", content: "" });
const [errors, setErrors] = useState<Errors>({});
const [touched, setTouched] = useState<Record<string, boolean>>({});
// バリデーション関数
const validate = (data: typeof formData): Errors => {
const errors: Errors = {};
if (!data.title.trim()) {
errors.title = "タイトルは必須です";
} else if (data.title.length > 100) {
errors.title = "タイトルは100文字以内で入力してください";
}
if (!data.content.trim()) {
errors.content = "本文は必須です";
} else if (data.content.length < 10) {
errors.content = "本文は10文字以上で入力してください";
}
return errors;
};
const handleChange = (
e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>
) => {
const { name, value } = e.target;
const newData = { ...formData, [name]: value };
setFormData(newData);
// 入力中もバリデーション(touchedのフィールドのみ表示)
if (touched[name]) {
setErrors(validate(newData));
}
};
const handleBlur = (e: React.FocusEvent) => {
const { name } = e.target;
setTouched((prev) => ({ ...prev, [name]: true }));
setErrors(validate(formData));
};
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
// 全フィールドをtouchedに
setTouched({ title: true, content: true });
const validationErrors = validate(formData);
setErrors(validationErrors);
if (Object.keys(validationErrors).length === 0) {
// バリデーション成功
console.log("送信:", formData);
}
};
return (
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label className="block font-bold mb-1">タイトル</label>
<input
type="text"
name="title"
value={formData.title}
onChange={handleChange}
onBlur={handleBlur}
className={`w-full border rounded px-3 py-2 ${
errors.title && touched.title ? "border-red-500" : ""
}`}
/>
{errors.title && touched.title && (
<p className="text-red-500 text-sm mt-1">{errors.title}</p>
)}
</div>
<div>
<label className="block font-bold mb-1">本文</label>
<textarea
name="content"
value={formData.content}
onChange={handleChange}
onBlur={handleBlur}
className={`w-full border rounded px-3 py-2 h-32 ${
errors.content && touched.content ? "border-red-500" : ""
}`}
/>
{errors.content && touched.content && (
<p className="text-red-500 text-sm mt-1">{errors.content}</p>
)}
</div>
<button
type="submit"
className="bg-blue-600 text-white px-4 py-2 rounded"
>
投稿する
</button>
</form>
);
}
再利用可能なInputコンポーネント
components/ui/Input.tsx
type InputProps = {
label: string;
name: string;
type?: string;
value: string;
onChange: (e: React.ChangeEvent<HTMLInputElement>) => void;
onBlur?: (e: React.FocusEvent<HTMLInputElement>) => void;
error?: string;
placeholder?: string;
};
export function Input({
label,
name,
type = "text",
value,
onChange,
onBlur,
error,
placeholder,
}: InputProps) {
return (
<div className="mb-4">
<label htmlFor={name} className="block font-bold mb-1">
{label}
</label>
<input
id={name}
name={name}
type={type}
value={value}
onChange={onChange}
onBlur={onBlur}
placeholder={placeholder}
className={`w-full border rounded px-3 py-2 ${
error ? "border-red-500 bg-red-50" : "border-gray-300"
}`}
/>
{error && <p className="text-red-500 text-sm mt-1">{error}</p>}
</div>
);
}
ファイルアップロード
"use client";
import { useState } from "react";
export function FileUpload() {
const [file, setFile] = useState<File | null>(null);
const [preview, setPreview] = useState<string | null>(null);
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const selectedFile = e.target.files?.[0];
if (selectedFile) {
setFile(selectedFile);
// プレビュー表示
const reader = new FileReader();
reader.onloadend = () => {
setPreview(reader.result as string);
};
reader.readAsDataURL(selectedFile);
}
};
const handleUpload = async () => {
if (!file) return;
const formData = new FormData();
formData.append("file", file);
const res = await fetch("/api/upload", {
method: "POST",
body: formData,
});
if (res.ok) {
alert("アップロード完了!");
}
};
return (
<div className="space-y-4">
<input
type="file"
accept="image/*"
onChange={handleFileChange}
className="block"
/>
{preview && (
<img src={preview} alt="Preview" className="w-48 h-48 object-cover" />
)}
<button
onClick={handleUpload}
disabled={!file}
className="bg-blue-600 text-white px-4 py-2 rounded disabled:opacity-50"
>
アップロード
</button>
</div>
);
}
まとめ
- ✓ Controlled Componentでフォームを管理
- ✓ バリデーションはフィールドごとに実装
- ✓ touched状態でエラー表示タイミングを制御
- ✓ 送信中は二重送信防止(disabled)