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\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\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\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 {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

