第5部:高度なフロントエンド Step 15 / 24

フォーム処理

フォームはユーザー入力の要。バリデーション、エラー表示、送信処理を適切に実装する方法を学びます。

基本的なフォーム(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)
状態管理 次へ:データフェッチ