React Controlled Component の実務活用ガイド|フォーム管理の実装パターン解説

React / Next.js

React Controlled Component の実務活用ガイド|フォーム管理の実装パターン解説

\n\n

はじめに:Controlled Component とは

\n\n

Reactを使ってフォーム開発をする際に、最初につまずくのが「Controlled Component」という概念です。教科書的な説明では「React がフォームの値を管理する」という説明がされていますが、実務ではもっと具体的で実践的な使い方があります。

\n\n

Controlled Component は、フォームの入力値を React の state で管理し、ユーザーの入力に応じて state を更新することで、常に React が真実の源(Single Source of Truth)となる設計パターンです。単なる理論ではなく、実務でのバリデーション、複数フィールド管理、フォーム値の永続化といった要件に対応するための実装方法なのです。

\n\n

実務における Controlled Component の位置づけ

\n\n

実務でフォーム開発をしていると、以下のような要件が必ず出てきます:

\n\n

    \n

  • 入力値のリアルタイムバリデーション
  • \n

  • 他のフィールドの値に基づいた動的な制御
  • \n

  • フォーム送信時の値の取得と処理
  • \n

  • 初期値の設定と値の初期化
  • \n

  • 複数ステップのフォーム(ウィザード形式)での値管理
  • \n

  • フォーム入力の自動保存(ドラフト機能)
  • \n

\n\n

これらすべてのケースで、Controlled Component が最適なパターンになります。では、具体的にどのように実装するのか、実務で使うコードを見ていきましょう。

\n\n

基本的な実装パターン

\n\n

まずは最もシンプルな例から始めます。ユーザー情報の登録フォームを想定してください。

\n\n

import React, { useState } from 'react';\n\ninterface UserFormData {\n  name: string;\n  email: string;\n  age: number;\n}\n\nconst UserRegistrationForm: React.FC = () => {\n  const [formData, setFormData] = useState<UserFormData>({\n    name: '',\n    email: '',\n    age: 0,\n  });\n\n  const handleInputChange = (\n    event: React.ChangeEvent<HTMLInputElement>\n  ) => {\n    const { name, value, type } = event.target;\n    setFormData((prevData) => ({\n      ...prevData,\n      [name]: type === 'number' ? Number(value) : value,\n    }));\n  };\n\n  const handleSubmit = (event: React.FormEvent) => {\n    event.preventDefault();\n    console.log('送信データ:', formData);\n    // API呼び出しなど\n  };\n\n  return (\n    
\n
\n \n \n
\n
\n \n \n
\n
\n \n \n
\n \n
\n );\n};\n\nexport default UserRegistrationForm;

\n\n

このコードの重要な点は、すべての input 要素が value 属性に state の値を持っており、onChange イベントハンドラで state を更新しているという点です。これが Controlled Component の本質です。

\n\n

実務で使う応用パターン①:バリデーション付きフォーム

\n\n

実務では、入力値のバリデーションが必ず必要です。リアルタイムエラー表示を含めた実装例を見てみましょう。

\n\n

import React, { useState } from 'react';\n\ninterface FormField {\n  value: string;\n  error: string | null;\n}\n\ninterface ValidationErrors {\n  email: string | null;\n  password: string | null;\n  passwordConfirm: string | null;\n}\n\nconst LoginForm: React.FC = () => {\n  const [formData, setFormData] = useState({\n    email: '',\n    password: '',\n    passwordConfirm: '',\n  });\n\n  const [errors, setErrors] = useState({\n    email: null,\n    password: null,\n    passwordConfirm: null,\n  });\n\n  const [touched, setTouched] = useState({\n    email: false,\n    password: false,\n    passwordConfirm: false,\n  });\n\n  const validateEmail = (email: string): string | null => {\n    const emailRegex = /^[^\\s@]+@[^\\s@]+\\.[^\\s@]+$/;\n    if (!email) return 'メールアドレスは必須です';\n    if (!emailRegex.test(email)) return '正しいメールアドレスを入力してください';\n    return null;\n  };\n\n  const validatePassword = (password: string): string | null => {\n    if (!password) return 'パスワードは必須です';\n    if (password.length < 8) return 'パスワードは8文字以上である必要があります';\n    if (!/[A-Z]/.test(password)) return '大文字を含める必要があります';\n    if (!/[0-9]/.test(password)) return '数字を含める必要があります';\n    return null;\n  };\n\n  const validatePasswordConfirm = (\n    password: string,\n    passwordConfirm: string\n  ): string | null => {\n    if (!passwordConfirm) return 'パスワード確認は必須です';\n    if (password !== passwordConfirm) return 'パスワードが一致しません';\n    return null;\n  };\n\n  const handleInputChange = (\n    event: React.ChangeEvent\n  ) => {\n    const { name, value } = event.target;\n    setFormData((prev) => ({ ...prev, [name]: value }));\n\n    // リアルタイムバリデーション\n    if (touched[name as keyof typeof touched]) {\n      validateField(name, value);\n    }\n  };\n\n  const validateField = (fieldName: string, value: string) => {\n    let error: string | null = null;\n\n    switch (fieldName) {\n      case 'email':\n        error = validateEmail(value);\n        break;\n      case 'password':\n        error = validatePassword(value);\n        // パスワード確認もバリデーション\n        if (touched.passwordConfirm && formData.passwordConfirm) {\n          validateField('passwordConfirm', formData.passwordConfirm);\n        }\n        break;\n      case 'passwordConfirm':\n        error = validatePasswordConfirm(formData.password, value);\n        break;\n    }\n\n    setErrors((prev) => ({ ...prev, [fieldName]: error }));\n  };\n\n  const handleBlur = (event: React.FocusEvent) => {\n    const { name } = event.target;\n    setTouched((prev) => ({ ...prev, [name]: true }));\n    validateField(name, formData[name as keyof typeof formData]);\n  };\n\n  const handleSubmit = (event: React.FormEvent) => {\n    event.preventDefault();\n\n    // 最終バリデーション\n    const emailError = validateEmail(formData.email);\n    const passwordError = validatePassword(formData.password);\n    const passwordConfirmError = validatePasswordConfirm(\n      formData.password,\n      formData.passwordConfirm\n    );\n\n    setErrors({\n      email: emailError,\n      password: passwordError,\n      passwordConfirm: passwordConfirmError,\n    });\n\n    if (emailError || passwordError || passwordConfirmError) {\n      return;\n    }\n\n    console.log('登録処理を実行:', formData);\n  };\n\n  return (\n    
\n
\n \n \n {errors.email && touched.email && (\n

{errors.email}

\n )}\n
\n\n
\n \n \n {errors.password && touched.password && (\n

{errors.password}

\n )}\n
\n\n
\n \n \n {errors.passwordConfirm && touched.passwordConfirm && (\n

{errors.passwordConfirm}

\n )}\n
\n\n \n
\n );\n};\n\nexport default LoginForm;

\n\n

このパターンは実務でよく使われます。重要な点は以下の通りです:

\n\n

    \n

  • touched 状態:ユーザーがフィールドをブラーしたか追跡し、ブラーするまではエラーを表示しない
  • \n

  • リアルタイムバリデーション:入力中にバリデーションを実行するが、ブラーするまで表示しない
  • \n

  • 相互依存性:パスワード確認はパスワード値に依存するため、パスワード変更時に再バリデーション
  • \n

\n\n

実務で使う応用パターン②:複数フィールドの複雑な連動

\n\n

EC サイトの注文フォームなど、複数のフィールドが相互に影響する場合があります。以下は住所選択と配送方法の連動例です。

\n\n

import React, { useState, useEffect } from 'react';\n\ninterface ShippingOption {\n  id: string;\n  name: string;\n  price: number;\n  daysToDeliver: number;\n}\n\ninterface OrderFormData {\n  prefecture: string;\n  city: string;\n  shippingMethod: string;\n  expressDelivery: boolean;\n}\n\nconst OrderForm: React.FC = () => {\n  const [formData, setFormData] = useState({\n    prefecture: '',\n    city: '',\n    shippingMethod: 'standard',\n    expressDelivery: false,\n  });\n\n  const [availableShippingMethods, setAvailableShippingMethods] = useState<\n    ShippingOption[]\n  >([]);\n\n  const [totalPrice, setTotalPrice] = useState(0);\n\n  // 都道府県ごとの配送方法を定義(本来はAPIから取得)\n  const shippingMethodsByPrefecture: Record = {\n    tokyo: [\n      { id: 'standard', name: '通常配送', price: 500, daysToDeliver: 3 },\n      { id: 'express', name: '翌日配送', price: 1000, daysToDeliver: 1 },\n    ],\n    osaka: [\n      { id: 'standard', name: '通常配送', price: 600, daysToDeliver: 3 },\n      { id: 'express', name: '翌日配送', price: 1200, daysToDeliver: 1 },\n    ],\n    rural: [\n      { id: 'standard', name: '通常配送', price: 1000, daysToDeliver: 7 },\n    ],\n  };\n\n  // 都道府県が変更されたら配送方法を更新\n  useEffect(() => {\n    if (formData.prefecture) {\n      const methods = shippingMethodsByPrefecture[formData.prefecture] || [];\n      setAvailableShippingMethods(methods);\n      // 配送方法をリセット\n      setFormData((prev) => ({\n        ...prev,\n        shippingMethod: methods[0]?.id || '',\n        expressDelivery: false,\n      }));\n    }\n  }, [formData.prefecture]);\n\n  // 配送方法が変更されたら配送料を計算\n  useEffect(() => {\n    const basePrice = 10000; // 商品代金\n    const selectedMethod = availableShippingMethods.find(\n      (m) => m.id === formData.shippingMethod\n    );\n    const shippingPrice = selectedMethod?.price || 0;\n    const expressCharge = formData.expressDelivery ? 500 : 0;\n    setTotalPrice(basePrice + shippingPrice + expressCharge);\n  }, [formData.shippingMethod, formData.expressDelivery, availableShippingMethods]);\n\n  const handleInputChange = (\n    event: React.ChangeEvent\n  ) => {\n    const { name, type, value } = event.target;\n    const inputElement = event.target as HTMLInputElement;\n\n    setFormData((prev) => ({\n      ...prev,\n      [name]:\n        type === 'checkbox'\n          ? inputElement.checked\n          : value,\n    }));\n  };\n\n  const handleSubmit = (event: React.FormEvent) => {\n    event.preventDefault();\n    console.log('注文データ:', formData);\n    console.log('合計金額:', totalPrice);\n  };\n\n  return (\n    
\n
\n \n \n \n \n \n \n \n
\n\n {formData.prefecture && (\n <>\n
\n \n \n
\n\n
\n \n \n {availableShippingMethods.map((method) => (\n \n ))}\n \n
\n\n
\n \n
\n\n
\n

商品代金: ¥10,000

\n

\n 配送料: ¥\n {availableShippingMethods.find(\n (m) => m.id === formData.shippingMethod\n )?.price || 0}\n

\n {formData.expressDelivery &&

時間指定料: ¥500

}\n

合計: ¥{totalPrice}

\n
\n \n )}\n\n \n
\n );\n};\n\nexport default OrderForm;

\n\n

このパターンで重要なのは useEffect を使った連動管理です。都道府県が変わると自動的に配送方法が更新され、配送方法が変わると合計金額が自動計算されます。これはユーザー体験を大きく向上させます。

\n\n

実務で使う応用パターン③:フォーム値の自動保存(ドラフト機能)

\n\n

長いフォームを途中で離脱するユーザーに対応するため、フォーム値を自動保存する実装も実務では一般的です。

\n\n

import React, { useState, useEffect, useRef } from 'react';\n\ninterface ArticleFormData {\n  title: string;\n  content: string;\n  category: string;\n  tags: string;\n}\n\nconst ArticleEditor: React.FC = () => {\n  const [formData, setFormData] = useState({\n    title: '',\n    content: '',\n    category: '',\n    tags: '',\n  });\n\n  const [lastSavedTime, setLastSavedTime] = useState(null);\n  const [isSaving, setIsSaving] = useState(false);\n  const autoSaveTimerRef = useRef(null);\n\n  // コンポーネントマウント時にローカルストレージから復元\n  useEffect(() => {\n    const savedData = localStorage.getItem('articleFormDraft');\n    if (savedData) {\n      try {\n        setFormData(JSON.parse(savedData));\n      } catch (error) {\n        console.error('ドラフト復元エラー:', error);\n      }\n    }\n  }, []);\n\n  // フォーム値が変更されるたびに自動保存をスケジュール\n  useEffect(() => {\n    // 前のタイマーをクリア\n    if (autoSaveTimerRef.current) {\n      clearTimeout(autoSaveTimerRef.current);\n    }\n\n    // 3秒後に自動保存\n    autoSaveTimerRef.current = setTimeout(() => {\n      saveFormData();\n    }, 3000);\n\n    // クリーンアップ\n    return () => {\n      if (autoSaveTimerRef.current) {\n        clearTimeout(autoSaveTimerRef.current);\n      }\n    };\n  }, [formData]);\n\n  const saveFormData = async () => {\n    setIsSaving(true);\n    try {\n      // ローカルストレージに保存\n      localStorage.setItem('articleFormDraft', JSON.stringify(formData));\n\n      // サーバーにも保存(オプション)\n      const response = await fetch('/api/articles/draft', {\n        method: 'POST',\n        headers: { 'Content-Type': 'application/json' },\n        body: JSON.stringify(formData),\n      });\n\n      if (response.ok) {\n        setLastSavedTime(new Date());\n      }\n    } catch (error) {\n      console.error('保存エラー:', error);\n    } finally {\n      setIsSaving(false);\n    }\n  };\n\n  const handleInputChange = (\n    event: React.ChangeEvent<\n      HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement\n    >\n  ) => {\n    const { name, value } = event.target;\n    setFormData((prev) => ({ ...prev, [name]: value }));\n  };\n\n  const handleSubmit = (event: React.FormEvent) => {\n    event.preventDefault();\n    console.log('記事を公開:', formData);\n    // 公開処理後、ドラフトを削除\n    localStorage.removeItem('articleFormDraft');\n  };\n\n  const handleClearDraft = () => {\n    if (window.confirm('ドラフトを削除しますか?')) {\n      localStorage.removeItem('articleFormDraft');\n      setFormData({ title: '', content: '', category: '', tags: '' });\n    }\n  };\n\n  return (\n    
\n
\n
\n \n \n
\n\n
\n \n \n \n \n \n \n \n
\n\n
\n \n \n
\n\n
\n \n \n
\n\n
\n \n \n
\n
\n\n
\n {isSaving && 保存中...}\n {lastSavedTime && !isSaving && (\n \n 最終保存: {lastSavedTime.toLocaleTimeString('ja-JP')}\n \n )}\n
\n
\n );\n};\n\nexport default ArticleEditor;

\n\n

このパターンは大型フォーム(ブログ記事エディタ、申請書類など)で特に有効です。3 秒のデバウンスを使うことで、過度なストレージアクセスを避けながら安全にデータを保存できます。

\n\n

実務で使う応用パターン④:カスタムフック化による再利用

\n\n

複数のフォームで同じロジックを使う場合は、カスタムフックにまとめるのが実務のベストプラクティスです。

\n\n


      
タイトルとURLをコピーしました