iT邦幫忙

2025 iThome 鐵人賽

DAY 8
0
Modern Web

30 天製作工作室 SaaS 產品 (前端篇)系列 第 8

Day 8: React Hook Form 與進階表單處理

  • 分享至 

  • xImage
  •  

前情提要

昨天我們建立了 React Query 資料狀態管理,學會了如何優化地處理 API 請求、快取和同步。今天我們要轉向前端開發的另一個重要議題:表單處理與驗證

在現代前端應用中,表單是使用者互動的核心,尤其是在 OTP 服務中:

  • 📱 手機號碼輸入與格式驗證
  • 📝 OTP 驗證碼輸入
  • 🎯 即時錯誤提示與用戶體驗
  • 🔄 與後端 API 的整合

為什麼選擇 React Hook Form?

React Hook Form 在現代 React 開發中脫穎而出的原因:

🚀 效能優勢

// ❌ 傳統受控組件:每次輸入都會重新渲染
const [phone, setPhone] = useState('');
const [otp, setOtp] = useState('');

// 每次輸入都觸發整個組件重新渲染
<input value={phone} onChange={(e) => setPhone(e.target.value)} />

// ✅ React Hook Form:最小化重新渲染
const { register, handleSubmit } = useForm();

// 只在必要時才重新渲染
<input {...register('phone')} />

🎯 內建驗證系統

// ❌ 手動驗證:繁瑣且容易出錯
const [errors, setErrors] = useState({});

const validatePhone = (phone) => {
  if (!phone) return '手機號碼為必填';
  if (!/^\+886\d{9}$/.test(phone)) return '手機號碼格式錯誤';
  return null;
};

// ✅ React Hook Form:宣告式驗證
const { register } = useForm();

<input
  {...register('phone', {
    required: '手機號碼為必填',
    pattern: {
      value: /^\+886\d{9}$/,
      message: '手機號碼格式錯誤'
    }
  })}
/>

實作步驟

1. 安裝相關套件

cd apps/kyo-dashboard
pnpm add react-hook-form @hookform/resolvers zod

2. 建立驗證 Schema

使用 Zod 建立型別安全的驗證規則:

// src/schemas/otpSchemas.ts
import { z } from 'zod';

// 台灣手機號碼格式驗證
const phoneRegex = /^(\+886|0)(9\d{8})$/;

export const otpSendSchema = z.object({
  phone: z
    .string()
    .min(1, '手機號碼為必填')
    .regex(phoneRegex, '請輸入正確的台灣手機號碼格式'),
  templateId: z
    .number()
    .optional()
    .nullable(),
});

export const otpVerifySchema = z.object({
  phone: z
    .string()
    .min(1, '手機號碼為必填')
    .regex(phoneRegex, '請輸入正確的台灣手機號碼格式'),
  otp: z
    .string()
    .min(1, '驗證碼為必填')
    .length(6, '驗證碼必須為 6 位數字')
    .regex(/^\d{6}$/, '驗證碼只能包含數字'),
});

// 模板管理表單
export const templateSchema = z.object({
  name: z
    .string()
    .min(1, '模板名稱為必填')
    .max(50, '模板名稱不能超過 50 個字元'),
  content: z
    .string()
    .min(1, '模板內容為必填')
    .max(500, '模板內容不能超過 500 個字元')
    .refine(
      (content) => content.includes('{code}'),
      '模板內容必須包含 {code} 占位符'
    ),
  isActive: z.boolean().default(true),
});

// 推斷 TypeScript 類型
export type OtpSendForm = z.infer<typeof otpSendSchema>;
export type OtpVerifyForm = z.infer<typeof otpVerifySchema>;
export type TemplateForm = z.infer<typeof templateSchema>;

3. 建立可重用的表單 Hook

// src/hooks/useValidatedForm.ts
import { useForm, UseFormProps } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';

interface UseValidatedFormProps<T extends z.ZodType> extends UseFormProps<z.infer<T>> {
  schema: T;
}

export function useValidatedForm<T extends z.ZodType>({
  schema,
  ...formProps
}: UseValidatedFormProps<T>) {
  return useForm<z.infer<T>>({
    resolver: zodResolver(schema),
    mode: 'onBlur', // 在失去焦點時驗證
    reValidateMode: 'onChange', // 在輸入時重新驗證
    ...formProps,
  });
}

4. OTP 發送表單組件

// src/components/forms/OtpSendForm.tsx
import { useState } from 'react';
import {
  TextInput,
  Button,
  Select,
  Stack,
  Group,
  Alert,
  Paper,
  Title,
} from '@mantine/core';
import { IconCheck, IconAlertCircle } from '@tabler/icons-react';
import { useValidatedForm } from '../../hooks/useValidatedForm';
import { useSendOtp } from '../../hooks/useOtp';
import { useTemplates } from '../../hooks/useTemplates';
import { otpSendSchema, type OtpSendForm } from '../../schemas/otpSchemas';

interface OtpSendFormProps {
  onSuccess?: (data: { msgId: string; phone: string }) => void;
}

export function OtpSendForm({ onSuccess }: OtpSendFormProps) {
  const [successData, setSuccessData] = useState<{ msgId: string; phone: string } | null>(null);

  const {
    register,
    handleSubmit,
    formState: { errors, isSubmitting },
    watch,
    reset,
  } = useValidatedForm({
    schema: otpSendSchema,
    defaultValues: {
      phone: '',
      templateId: null,
    },
  });

  const { data: templates, isLoading: templatesLoading } = useTemplates();
  const sendOtpMutation = useSendOtp();

  const onSubmit = async (data: OtpSendForm) => {
    try {
      // 格式化手機號碼
      const formattedPhone = data.phone.startsWith('+886')
        ? data.phone
        : data.phone.replace(/^0/, '+886');

      const result = await sendOtpMutation.mutateAsync({
        phone: formattedPhone,
        templateId: data.templateId || undefined,
      });

      setSuccessData({
        msgId: result.msgId,
        phone: formattedPhone,
      });

      onSuccess?.(result);
      reset(); // 重置表單
    } catch (error) {
      // 錯誤已經由 React Query 的 onError 處理
      console.error('Send OTP failed:', error);
    }
  };

  // 監聽手機號碼輸入,提供即時格式提示
  const phoneValue = watch('phone');
  const getPhoneFormatHint = (phone: string) => {
    if (!phone) return '請輸入手機號碼(例:0987654321 或 +886987654321)';
    if (phone.startsWith('09') && phone.length === 10) {
      return `將轉換為:+886${phone.slice(1)}`;
    }
    if (phone.startsWith('+886') && phone.length === 13) {
      return '✓ 格式正確';
    }
    return '請輸入正確的台灣手機號碼';
  };

  if (successData) {
    return (
      <Paper p="xl" shadow="sm" radius="md">
        <Stack>
          <Alert
            icon={<IconCheck size="1rem" />}
            title="驗證碼已發送"
            color="green"
            variant="light"
          >
            <Stack gap="xs">
              <div>訊息 ID: <strong>{successData.msgId}</strong></div>
              <div>發送至: <strong>{successData.phone}</strong></div>
              <div>請查看您的簡訊並輸入 6 位數驗證碼</div>
            </Stack>
          </Alert>

          <Button
            variant="outline"
            onClick={() => setSuccessData(null)}
          >
            發送另一個驗證碼
          </Button>
        </Stack>
      </Paper>
    );
  }

  return (
    <Paper p="xl" shadow="sm" radius="md">
      <form onSubmit={handleSubmit(onSubmit)}>
        <Stack>
          <Title order={3}>發送 OTP 驗證碼</Title>

          <TextInput
            label="手機號碼"
            placeholder="0987654321 或 +886987654321"
            description={getPhoneFormatHint(phoneValue || '')}
            error={errors.phone?.message}
            {...register('phone')}
          />

          <Select
            label="簡訊模板"
            placeholder="選擇模板(可選)"
            description="未選擇將使用預設模板"
            data={templates?.map(template => ({
              value: template.id.toString(),
              label: template.name,
              disabled: !template.isActive,
            })) || []}
            loading={templatesLoading}
            clearable
            {...register('templateId', {
              setValueAs: (value) => value ? parseInt(value, 10) : null,
            })}
          />

          {sendOtpMutation.error && (
            <Alert
              icon={<IconAlertCircle size="1rem" />}
              title="發送失敗"
              color="red"
              variant="light"
            >
              {sendOtpMutation.error.message}
            </Alert>
          )}

          <Group justify="flex-end">
            <Button
              type="submit"
              loading={isSubmitting || sendOtpMutation.isPending}
              disabled={Object.keys(errors).length > 0}
            >
              發送驗證碼
            </Button>
          </Group>
        </Stack>
      </form>
    </Paper>
  );
}

5. OTP 驗證表單組件

// src/components/forms/OtpVerifyForm.tsx
import { useState, useEffect } from 'react';
import {
  TextInput,
  Button,
  Stack,
  Group,
  Alert,
  Paper,
  Title,
  Text,
  PinInput,
} from '@mantine/core';
import { IconCheck, IconAlertCircle } from '@tabler/icons-react';
import { useValidatedForm } from '../../hooks/useValidatedForm';
import { useVerifyOtp } from '../../hooks/useOtp';
import { otpVerifySchema, type OtpVerifyForm } from '../../schemas/otpSchemas';

interface OtpVerifyFormProps {
  initialPhone?: string;
  onSuccess?: (data: { success: boolean }) => void;
}

export function OtpVerifyForm({ initialPhone, onSuccess }: OtpVerifyFormProps) {
  const [countdown, setCountdown] = useState(0);

  const {
    register,
    handleSubmit,
    formState: { errors, isSubmitting },
    setValue,
    watch,
  } = useValidatedForm({
    schema: otpVerifySchema,
    defaultValues: {
      phone: initialPhone || '',
      otp: '',
    },
  });

  const verifyOtpMutation = useVerifyOtp();

  // 倒數計時器(防止重複發送)
  useEffect(() => {
    if (countdown > 0) {
      const timer = setTimeout(() => setCountdown(countdown - 1), 1000);
      return () => clearTimeout(timer);
    }
  }, [countdown]);

  const onSubmit = async (data: OtpVerifyForm) => {
    try {
      // 格式化手機號碼
      const formattedPhone = data.phone.startsWith('+886')
        ? data.phone
        : data.phone.replace(/^0/, '+886');

      const result = await verifyOtpMutation.mutateAsync({
        phone: formattedPhone,
        otp: data.otp,
      });

      if (result.success) {
        onSuccess?.(result);
      }
    } catch (error) {
      console.error('Verify OTP failed:', error);
    }
  };

  const otpValue = watch('otp');

  return (
    <Paper p="xl" shadow="sm" radius="md">
      <form onSubmit={handleSubmit(onSubmit)}>
        <Stack>
          <Title order={3}>驗證 OTP 驗證碼</Title>

          <TextInput
            label="手機號碼"
            placeholder="0987654321 或 +886987654321"
            error={errors.phone?.message}
            {...register('phone')}
          />

          <Stack gap="xs">
            <Text size="sm" fw={500}>驗證碼</Text>
            <PinInput
              length={6}
              type="number"
              placeholder="○"
              value={otpValue || ''}
              onChange={(value) => setValue('otp', value)}
              error={!!errors.otp}
            />
            {errors.otp && (
              <Text size="xs" c="red">
                {errors.otp.message}
              </Text>
            )}
            <Text size="xs" c="dimmed">
              請輸入收到的 6 位數驗證碼
            </Text>
          </Stack>

          {verifyOtpMutation.error && (
            <Alert
              icon={<IconAlertCircle size="1rem" />}
              title="驗證失敗"
              color="red"
              variant="light"
            >
              {verifyOtpMutation.error.message}
            </Alert>
          )}

          {verifyOtpMutation.data && !verifyOtpMutation.data.success && (
            <Alert
              icon={<IconAlertCircle size="1rem" />}
              title="驗證碼錯誤"
              color="yellow"
              variant="light"
            >
              <Stack gap="xs">
                <div>剩餘嘗試次數:{verifyOtpMutation.data.attemptsLeft || 0}</div>
                {verifyOtpMutation.data.attemptsLeft === 0 && (
                  <div>帳號已被鎖定,請稍後再試</div>
                )}
              </Stack>
            </Alert>
          )}

          <Group justify="space-between">
            <Button
              variant="subtle"
              disabled={countdown > 0}
              onClick={() => {
                // 觸發重新發送 OTP 的邏輯
                setCountdown(60);
              }}
            >
              {countdown > 0 ? `重新發送 (${countdown}s)` : '重新發送驗證碼'}
            </Button>

            <Button
              type="submit"
              loading={isSubmitting || verifyOtpMutation.isPending}
              disabled={Object.keys(errors).length > 0 || !otpValue || otpValue.length !== 6}
            >
              驗證
            </Button>
          </Group>
        </Stack>
      </form>
    </Paper>
  );
}

6. 模板管理表單組件

// src/components/forms/TemplateForm.tsx
import {
  TextInput,
  Textarea,
  Button,
  Stack,
  Group,
  Switch,
  Paper,
  Title,
  Text,
  Code,
} from '@mantine/core';
import { useValidatedForm } from '../../hooks/useValidatedForm';
import { useCreateTemplate, useUpdateTemplate } from '../../hooks/useTemplates';
import { templateSchema, type TemplateForm } from '../../schemas/otpSchemas';

interface TemplateFormProps {
  template?: {
    id: number;
    name: string;
    content: string;
    isActive: boolean;
  };
  onSuccess?: () => void;
  onCancel?: () => void;
}

export function TemplateForm({ template, onSuccess, onCancel }: TemplateFormProps) {
  const isEditing = !!template;

  const {
    register,
    handleSubmit,
    formState: { errors, isSubmitting },
    watch,
  } = useValidatedForm({
    schema: templateSchema,
    defaultValues: {
      name: template?.name || '',
      content: template?.content || '',
      isActive: template?.isActive ?? true,
    },
  });

  const createMutation = useCreateTemplate();
  const updateMutation = useUpdateTemplate();

  const onSubmit = async (data: TemplateForm) => {
    try {
      if (isEditing) {
        await updateMutation.mutateAsync({ id: template.id, ...data });
      } else {
        await createMutation.mutateAsync(data);
      }
      onSuccess?.();
    } catch (error) {
      console.error('Save template failed:', error);
    }
  };

  const contentValue = watch('content');
  const getPreview = (content: string) => {
    return content.replace('{code}', '123456');
  };

  return (
    <Paper p="xl" shadow="sm" radius="md">
      <form onSubmit={handleSubmit(onSubmit)}>
        <Stack>
          <Title order={3}>
            {isEditing ? '編輯模板' : '新增模板'}
          </Title>

          <TextInput
            label="模板名稱"
            placeholder="例:登入驗證碼"
            error={errors.name?.message}
            {...register('name')}
          />

          <Stack gap="xs">
            <Textarea
              label="模板內容"
              placeholder="您的驗證碼:{code},請於 5 分鐘內完成驗證。"
              description="使用 {code} 作為驗證碼的占位符"
              rows={4}
              error={errors.content?.message}
              {...register('content')}
            />

            {contentValue && contentValue.includes('{code}') && (
              <div>
                <Text size="sm" fw={500} mb={4}>預覽效果:</Text>
                <Code block p="sm">
                  {getPreview(contentValue)}
                </Code>
              </div>
            )}
          </Stack>

          <Switch
            label="啟用模板"
            description="停用的模板不會出現在發送選項中"
            {...register('isActive')}
          />

          <Group justify="flex-end">
            {onCancel && (
              <Button variant="outline" onClick={onCancel}>
                取消
              </Button>
            )}
            <Button
              type="submit"
              loading={isSubmitting || createMutation.isPending || updateMutation.isPending}
              disabled={Object.keys(errors).length > 0}
            >
              {isEditing ? '更新' : '建立'}
            </Button>
          </Group>
        </Stack>
      </form>
    </Paper>
  );
}

7. 整合到頁面中

// src/pages/OtpPage.tsx
import { useState } from 'react';
import { Container, Stack, Tabs } from '@mantine/core';
import { OtpSendForm } from '../components/forms/OtpSendForm';
import { OtpVerifyForm } from '../components/forms/OtpVerifyForm';

export function OtpPage() {
  const [activeTab, setActiveTab] = useState<string | null>('send');
  const [lastSentPhone, setLastSentPhone] = useState<string>('');

  const handleSendSuccess = (data: { msgId: string; phone: string }) => {
    setLastSentPhone(data.phone);
    setActiveTab('verify');
  };

  const handleVerifySuccess = () => {
    // 處理驗證成功的邏輯
    console.log('OTP verified successfully!');
  };

  return (
    <Container size="sm" py="xl">
      <Tabs value={activeTab} onChange={setActiveTab}>
        <Tabs.List grow>
          <Tabs.Tab value="send">發送驗證碼</Tabs.Tab>
          <Tabs.Tab value="verify">驗證驗證碼</Tabs.Tab>
        </Tabs.List>

        <Tabs.Panel value="send" pt="xl">
          <OtpSendForm onSuccess={handleSendSuccess} />
        </Tabs.Panel>

        <Tabs.Panel value="verify" pt="xl">
          <OtpVerifyForm
            initialPhone={lastSentPhone}
            onSuccess={handleVerifySuccess}
          />
        </Tabs.Panel>
      </Tabs>
    </Container>
  );
}

進階功能

1. 自訂驗證 Hook

// src/hooks/useAsyncValidation.ts
import { useState, useCallback } from 'react';
import { debounce } from 'lodash-es';

interface AsyncValidationOptions {
  debounceMs?: number;
}

export function useAsyncValidation<T>(
  validator: (value: T) => Promise<string | undefined>,
  options: AsyncValidationOptions = {}
) {
  const [isValidating, setIsValidating] = useState(false);
  const [error, setError] = useState<string | undefined>();

  const debouncedValidator = useCallback(
    debounce(async (value: T) => {
      setIsValidating(true);
      try {
        const result = await validator(value);
        setError(result);
      } catch (err) {
        setError('驗證發生錯誤');
      } finally {
        setIsValidating(false);
      }
    }, options.debounceMs || 500),
    [validator, options.debounceMs]
  );

  const validate = useCallback((value: T) => {
    if (!value) {
      setError(undefined);
      setIsValidating(false);
      return;
    }
    debouncedValidator(value);
  }, [debouncedValidator]);

  return { validate, isValidating, error };
}

// 使用範例:檢查手機號碼是否已被使用
export function usePhoneValidation() {
  return useAsyncValidation(async (phone: string) => {
    const response = await fetch(`/api/phone/check?phone=${encodeURIComponent(phone)}`);
    const result = await response.json();

    if (!response.ok) {
      throw new Error(result.message);
    }

    if (result.exists) {
      return '此手機號碼已被註冊';
    }

    return undefined;
  });
}

2. 表單狀態持久化

// src/hooks/useFormPersistence.ts
import { useEffect } from 'react';
import { UseFormReturn } from 'react-hook-form';

export function useFormPersistence<T>(
  form: UseFormReturn<T>,
  key: string,
  exclude: (keyof T)[] = []
) {
  const { watch, reset } = form;

  // 從 localStorage 恢復表單狀態
  useEffect(() => {
    const savedData = localStorage.getItem(`form:${key}`);
    if (savedData) {
      try {
        const parsed = JSON.parse(savedData);
        reset(parsed);
      } catch (error) {
        console.warn('Failed to restore form data:', error);
      }
    }
  }, [reset, key]);

  // 監聽表單變化並保存
  useEffect(() => {
    const subscription = watch((data) => {
      const dataToSave = { ...data };

      // 排除敏感欄位
      exclude.forEach(field => {
        delete dataToSave[field];
      });

      localStorage.setItem(`form:${key}`, JSON.stringify(dataToSave));
    });

    return () => subscription.unsubscribe();
  }, [watch, key, exclude]);

  // 清除保存的資料
  const clearSavedData = () => {
    localStorage.removeItem(`form:${key}`);
  };

  return { clearSavedData };
}

今天成果

通過整合 React Hook Form 和 Zod,我們建立了一個強大的表單處理系統:

型別安全驗證:使用 Zod 確保執行時型別安全
高效能表單:最小化重新渲染,提升使用者體驗
即時驗證:提供即時錯誤回饋和格式提示
可重用組件:建立了可複用的表單組件庫
無障礙支援:正確的 ARIA 標籤和鍵盤導航
進階功能:異步驗證、表單持久化等企業級功能


上一篇
Day 7: React Query 與資料狀態管理
系列文
30 天製作工作室 SaaS 產品 (前端篇)8
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言