React 表單處理 (Forms)

表單(form)元件透過和使用者互動的過程會產生資料狀態的變化。本篇將介紹 React 中處理表單的方式,包括傳統的受控元件方式,以及現代的 Actions 方式。

受控元件 (Controlled Components)

受控元件是指由 React 的 state 來控制表單元素值的元件。表單元素的值和 state 同步,每次輸入都會更新 state。

import { useState } from 'react'

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

  function handleSubmit(event) {
    event.preventDefault()
    console.log('提交的名字:', name)
  }

  return (
    <form onSubmit={handleSubmit}>
      <input type="text" value={name} onChange={(e) => setName(e.target.value)} />
      <button type="submit">送出</button>
    </form>
  )
}

特點:

  • 使用 value 設定表單元素的值
  • 使用 onChange 監聽變化並更新 state
  • 表單值完全由 React 控制

處理多個輸入欄位

當表單有多個欄位時,可以使用一個 state 物件來管理:

import { useState } from 'react'

function RegistrationForm() {
  const [formData, setFormData] = useState({
    username: '',
    email: '',
    password: '',
  })

  function handleChange(event) {
    const { name, value } = event.target
    setFormData((prev) => ({
      ...prev,
      [name]: value,
    }))
  }

  function handleSubmit(event) {
    event.preventDefault()
    console.log('表單資料:', formData)
  }

  return (
    <form onSubmit={handleSubmit}>
      <div>
        <label htmlFor="username">帳號:</label>
        <input
          type="text"
          id="username"
          name="username"
          value={formData.username}
          onChange={handleChange}
        />
      </div>
      <div>
        <label htmlFor="email">Email:</label>
        <input
          type="email"
          id="email"
          name="email"
          value={formData.email}
          onChange={handleChange}
        />
      </div>
      <div>
        <label htmlFor="password">密碼:</label>
        <input
          type="password"
          id="password"
          name="password"
          value={formData.password}
          onChange={handleChange}
        />
      </div>
      <button type="submit">註冊</button>
    </form>
  )
}

各種表單元素

<textarea>

在 React 中,<textarea> 使用 value 來設定內容:

<textarea value={message} onChange={(e) => setMessage(e.target.value)} />

<select>

使用 value 來設定選中的選項:

<select value={fruit} onChange={(e) => setFruit(e.target.value)}>
  <option value="apple">蘋果</option>
  <option value="banana">香蕉</option>
  <option value="orange">橘子</option>
</select>

多選的情況:

<select
  multiple
  value={selectedFruits}
  onChange={(e) => {
    const values = Array.from(e.target.selectedOptions, (opt) => opt.value)
    setSelectedFruits(values)
  }}
>
  <option value="apple">蘋果</option>
  <option value="banana">香蕉</option>
  <option value="orange">橘子</option>
</select>

<input type="checkbox">

使用 checked 屬性:

<input type="checkbox" checked={isAgree} onChange={(e) => setIsAgree(e.target.checked)} />

<input type="radio">

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

  return (
    <div>
      <label>
        <input
          type="radio"
          name="gender"
          value="male"
          checked={gender === 'male'}
          onChange={(e) => setGender(e.target.value)}
        />
        男
      </label>
      <label>
        <input
          type="radio"
          name="gender"
          value="female"
          checked={gender === 'female'}
          onChange={(e) => setGender(e.target.value)}
        />
        女
      </label>
    </div>
  )
}

非受控元件 (Uncontrolled Components)

非受控元件讓表單元素自己管理狀態,透過 ref 來取得值:

import { useRef } from 'react'

function UncontrolledForm() {
  const inputRef = useRef(null)

  function handleSubmit(event) {
    event.preventDefault()
    console.log('輸入的值:', inputRef.current.value)
  }

  return (
    <form onSubmit={handleSubmit}>
      <input type="text" ref={inputRef} defaultValue="預設值" />
      <button type="submit">送出</button>
    </form>
  )
}
非受控元件使用 defaultValue 而不是 value 來設定初始值。

<input type="file">

檔案輸入是特殊的非受控元件,因為它的值是唯讀的:

function FileUpload() {
  const fileRef = useRef(null)

  function handleSubmit(event) {
    event.preventDefault()
    const file = fileRef.current.files[0]
    console.log('選擇的檔案:', file.name)
  }

  return (
    <form onSubmit={handleSubmit}>
      <input type="file" ref={fileRef} />
      <button type="submit">上傳</button>
    </form>
  )
}

Form Actions(現代方式)

React 提供了一種更簡潔的表單處理方式:直接在 <form> 上使用 action 屬性。

基本用法

function SimpleForm() {
  async function handleSubmit(formData) {
    const name = formData.get('name')
    const email = formData.get('email')
    console.log('提交:', { name, email })
  }

  return (
    <form action={handleSubmit}>
      <input type="text" name="name" placeholder="姓名" />
      <input type="email" name="email" placeholder="Email" />
      <button type="submit">送出</button>
    </form>
  )
}

使用 action 的好處:

  • 不需要 event.preventDefault()
  • 自動收集表單資料為 FormData 物件
  • 可以直接使用 async 函式

useActionState

useActionState 是用來追蹤表單 action 狀態的 Hook:

import { useActionState } from 'react'

function LoginForm() {
  async function login(previousState, formData) {
    const email = formData.get('email')
    const password = formData.get('password')

    // 模擬 API 呼叫
    await new Promise((resolve) => setTimeout(resolve, 1000))

    if (email === 'test@example.com' && password === '123456') {
      return { success: true, message: '登入成功!' }
    }
    return { success: false, message: '帳號或密碼錯誤' }
  }

  const [state, formAction, isPending] = useActionState(login, null)

  return (
    <form action={formAction}>
      <div>
        <input type="email" name="email" placeholder="Email" required />
      </div>
      <div>
        <input type="password" name="password" placeholder="密碼" required />
      </div>

      <button type="submit" disabled={isPending}>
        {isPending ? '登入中...' : '登入'}
      </button>

      {state && <p style={{ color: state.success ? 'green' : 'red' }}>{state.message}</p>}
    </form>
  )
}

useActionState 返回:

  • state:action 的返回值(初始為傳入的第二個參數)
  • formAction:要傳給 form 的 action 函式
  • isPending:是否正在執行中

useFormStatus

useFormStatus 可以在子元件中取得父表單的狀態:

import { useFormStatus } from 'react-dom'

function SubmitButton() {
  const { pending } = useFormStatus()

  return (
    <button type="submit" disabled={pending}>
      {pending ? '提交中...' : '送出'}
    </button>
  )
}

function ContactForm() {
  async function handleSubmit(formData) {
    // 處理表單...
    await new Promise((resolve) => setTimeout(resolve, 2000))
  }

  return (
    <form action={handleSubmit}>
      <input type="text" name="name" placeholder="姓名" />
      <input type="email" name="email" placeholder="Email" />
      <textarea name="message" placeholder="訊息"></textarea>
      {/* SubmitButton 可以自動知道表單的 pending 狀態 */}
      <SubmitButton />
    </form>
  )
}
useFormStatus 必須在 <form> 的子元件中使用,不能在同一個元件中使用。

表單驗證

使用 HTML5 驗證

<form action={handleSubmit}>
  <input type="email" name="email" required />
  <input type="text" name="name" minLength={2} maxLength={50} required />
  <input type="number" name="age" min={0} max={150} />
  <button type="submit">送出</button>
</form>

自訂驗證

import { useState } from 'react'

function ValidatedForm() {
  const [errors, setErrors] = useState({})

  function validate(formData) {
    const newErrors = {}
    const email = formData.get('email')
    const password = formData.get('password')

    if (!email.includes('@')) {
      newErrors.email = '請輸入有效的 Email'
    }
    if (password.length < 8) {
      newErrors.password = '密碼至少需要 8 個字元'
    }

    return newErrors
  }

  async function handleSubmit(formData) {
    const validationErrors = validate(formData)

    if (Object.keys(validationErrors).length > 0) {
      setErrors(validationErrors)
      return
    }

    setErrors({})
    // 處理表單提交...
  }

  return (
    <form action={handleSubmit}>
      <div>
        <input type="email" name="email" placeholder="Email" />
        {errors.email && <span className="error">{errors.email}</span>}
      </div>
      <div>
        <input type="password" name="password" placeholder="密碼" />
        {errors.password && <span className="error">{errors.password}</span>}
      </div>
      <button type="submit">送出</button>
    </form>
  )
}

完整範例:會員註冊表單

import { useActionState } from 'react'
import { useFormStatus } from 'react-dom'

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

function RegistrationForm() {
  async function register(prevState, formData) {
    const data = {
      username: formData.get('username'),
      email: formData.get('email'),
      password: formData.get('password'),
      confirmPassword: formData.get('confirmPassword'),
    }

    // 驗證
    if (data.password !== data.confirmPassword) {
      return { success: false, error: '密碼不一致' }
    }

    if (data.password.length < 8) {
      return { success: false, error: '密碼至少需要 8 個字元' }
    }

    // 模擬 API 呼叫
    await new Promise((resolve) => setTimeout(resolve, 1500))

    // 模擬成功
    return { success: true, message: '註冊成功!請查看 Email 進行驗證。' }
  }

  const [state, formAction] = useActionState(register, null)

  return (
    <form action={formAction} className="registration-form">
      <h2>會員註冊</h2>

      <div className="form-group">
        <label htmlFor="username">帳號</label>
        <input type="text" id="username" name="username" required minLength={3} maxLength={20} />
      </div>

      <div className="form-group">
        <label htmlFor="email">Email</label>
        <input type="email" id="email" name="email" required />
      </div>

      <div className="form-group">
        <label htmlFor="password">密碼</label>
        <input type="password" id="password" name="password" required minLength={8} />
      </div>

      <div className="form-group">
        <label htmlFor="confirmPassword">確認密碼</label>
        <input type="password" id="confirmPassword" name="confirmPassword" required />
      </div>

      {state?.error && <p className="error-message">{state.error}</p>}

      {state?.success && <p className="success-message">{state.message}</p>}

      <SubmitButton />
    </form>
  )
}