shadcn之表单

前言

shadcn表单相关的了解,可知道 shadcn 目前推出了3种方式来处理表单(毕竟shadcn只做ui,要想处理好完整表单就涉及到可控、校验等问题):react-hook-formtanstackuseActionState
image

实际项目中,选一种你喜欢和熟悉的方式去用即可,不过目测第三者会是主流,毕竟是基于react官方特性!

react-hook-form版本

'use client';

// 导入表单验证库
import * as z from 'zod';
// 导入消息提示组件
import { toast } from 'sonner';
// 导入 UI 组件
import { Input } from '@repo/shadcn-comps/input';
import { Button } from '@repo/shadcn-comps/button';
import { Textarea } from '@repo/shadcn-comps/textarea';
import { Field, FieldError, FieldGroup, FieldLabel } from '@repo/shadcn-comps/field';
// 导入表单处理相关
import { zodResolver } from '@hookform/resolvers/zod';
import { Controller, useForm } from 'react-hook-form';

// 定义表单验证规则
const formSchema = z.object({
  title: z.string().min(5, '标题至少需要5个字符。').max(32, '标题最多32个字符。'),
  description: z.string().min(10, '描述至少需要10个字符。').max(30, '描述最多30个字符。'),
});

/**
 * 问题反馈表单组件
 * 使用 react-hook-form 进行表单管理,zod 进行表单验证
 */
export default function BugReportForm() {
  // 初始化表单,配置验证器和默认值
  const form = useForm<z.infer<typeof formSchema>>({
    resolver: zodResolver(formSchema), // 使用 zod 验证器
    defaultValues: {
      title: '',
      description: '',
    },
  });

  // 表单提交处理函数
  function onSubmit(data: z.infer<typeof formSchema>) {
    toast.info(JSON.stringify(data, null, 2));
  }

  return (
    <div className="p-2 w-100">
      {/* 表单主体 */}
      <form id="form-rhf-demo" onSubmit={form.handleSubmit(onSubmit)}>
        <FieldGroup>
          {/* title字段 - 使用 Controller 包装以集成 react-hook-form */}
          <Controller
            name="title"
            control={form.control}
            render={({ field, fieldState }) => (
              <Field data-invalid={fieldState.invalid}>
                <FieldLabel htmlFor="form-rhf-demo-title">标题</FieldLabel>
                <Input {...field} id="form-rhf-demo-title" aria-invalid={fieldState.invalid} placeholder="请输入" />
                {/* 显示验证错误信息 */}
                {fieldState.invalid && <FieldError errors={[fieldState.error]} />}
              </Field>
            )}
          />

          <Controller
            name="description"
            control={form.control}
            render={({ field, fieldState }) => (
              <Field data-invalid={fieldState.invalid}>
                <FieldLabel htmlFor="form-rhf-demo-description">描述</FieldLabel>
                <Textarea {...field} id="form-rhf-demo-description" placeholder="请输入" rows={6} className="min-h-24 resize-none" />
                {/* 显示验证错误信息 */}
                {fieldState.invalid && <FieldError errors={[fieldState.error]} />}
              </Field>
            )}
          />
        </FieldGroup>
      </form>

      {/* 操作按钮组 */}
      <Field orientation="horizontal" className="mt-2">
        <Button type="button" variant="outline" onClick={() => form.reset()}>
          重置
        </Button>
        <Button type="submit" form="form-rhf-demo">
          提交
        </Button>
      </Field>
    </div>
  );
}

tanstack模式

借助于 @tanstack/react-form 来实现

// 导入表单验证库
import * as z from 'zod';
// 导入消息提示组件
import { toast } from 'sonner';
// 导入表单处理相关
import { useForm } from '@tanstack/react-form';
// 导入 UI 组件
import { Input } from '@repo/shadcn-comps/input';
import { Button } from '@repo/shadcn-comps/button';
import { Textarea } from '@repo/shadcn-comps/textarea';
import { Field, FieldError, FieldGroup, FieldLabel } from '@repo/shadcn-comps/field';

// 定义表单验证规则
const formSchema = z.object({
  title: z.string().min(5, '标题至少需要5个字符。').max(32, '标题最多32个字符。'),
  description: z.string().min(10, '描述至少需要10个字符。').max(30, '描述最多30个字符。'),
});

/**
 * 问题反馈表单组件
 * 使用 @tanstack/react-form 进行表单管理,zod 进行表单验证
 */
export default function BugReportForm() {
  // 初始化表单
  const form = useForm({
    defaultValues: {
      title: '',
      description: '',
    },
    validators: {
      onSubmit: formSchema,
    },
    onSubmit: async ({ value }) => {
      console.log(898989);

      toast.info(JSON.stringify(value, null, 2));
    },
  });

  return (
    <div className="p-2 w-100">
      {/* 表单主体 */}
      <form
        id="form-tanstack-demo"
        onSubmit={(e) => {
          e.preventDefault();
          form.handleSubmit();
        }}
      >
        <FieldGroup>
          {/* title字段 */}
          <form.Field
            name="title"
            children={(field) => {
              const isInvalid = field.state.meta.isTouched && !field.state.meta.isValid;
              return (
                <Field data-invalid={isInvalid}>
                  <FieldLabel htmlFor={field.name}>标题</FieldLabel>
                  <Input id={field.name} name={field.name} value={field.state.value} onBlur={field.handleBlur} onChange={(e) => field.handleChange(e.target.value)} aria-invalid={isInvalid} placeholder="请输入" />
                  {isInvalid && <FieldError errors={field.state.meta.errors} />}
                </Field>
              );
            }}
          />
          {/* description字段 */}
          <form.Field
            name="description"
            children={(field) => {
              const isInvalid = field.state.meta.isTouched && !field.state.meta.isValid;
              return (
                <Field data-invalid={isInvalid}>
                  <FieldLabel htmlFor={field.name}>描述</FieldLabel>
                  <Textarea id={field.name} placeholder="请输入" rows={6} className="min-h-24 resize-none" onBlur={field.handleBlur} onChange={(e) => field.handleChange(e.target.value)} />
                  {isInvalid && <FieldError errors={field.state.meta.errors} />}
                </Field>
              );
            }}
          />
        </FieldGroup>
      </form>

      {/* 操作按钮组 */}
      <Field orientation="horizontal" className="mt-2">
        <Button type="button" variant="outline" onClick={() => form.reset()}>
          重置
        </Button>
        <Button type="submit" form="form-tanstack-demo">
          提交
        </Button>
      </Field>
    </div>
  );
}

useActionState版本

虽然shadcn官方还没有出此版本,但是我们可以自己实现

import _ from 'lodash';
import * as z from 'zod';
import cn from '@/utils/cn';
import * as api from '../api';
import { match } from 'ts-pattern';
import { useActionState, startTransition } from 'react';

type Error = { filed: 'string'; msg: string };

type SubmitResult =
  | {
      type: 'server';
      status: boolean;
      error: string;
    }
  | {
      type: 'client';
      status: boolean;
      error: Error[];
    }
  | null;

// 定义表单验证规则
const formSchema = z.object({
  title: z.string().min(5, '标题至少需要5个字符。').max(32, '标题最多32个字符。'),
  description: z.string().min(10, '描述至少需要10个字符。').max(30, '描述最多30个字符。'),
});

// React 19实现(通过useActionState结合非受控来管理"表单")
export default function formDemo() {
  const handleSubmit = async (_: SubmitResult, formData: FormData): Promise<SubmitResult> => {
    if (formData.has('reset')) return null;
    try {
      formSchema.parse(Object.fromEntries(formData.entries()));
    } catch (error) {
      const errors = JSON.parse(error) || [];
      return {
        type: 'client',
        status: errors.length === 0,
        error: errors.map((item) => ({
          filed: item.path.at(0),
          msg: item.message,
        })),
      };
    }
    if (formData.has('justCheckClient')) return null;

    const res = await api.submitComment(formData);
    return { type: 'server', status: res.code === 0, error: res.msg };
  };

  const [submitResult, formAction, isPending] = useActionState(handleSubmit, null);

  const onReset = () => {
    // 因为重置不会调用formAction,所以我们要手动调用,便于重置submitResult
    startTransition(() => {
      const formData = new FormData();
      formData.append('reset', '');
      formAction(formData);
    });
  };

  const onChange = (e: React.FormEvent<HTMLFormElement>) => {
    if (submitResult && submitResult.error.length > 0) {
      const formData = new FormData(e.currentTarget as HTMLFormElement);
      formData.append('justCheckClient', '');
      formAction(formData);
    }
  };

  const showError = (filed: string) => {
    if (!submitResult) return null;
    const msg = submitResult.type === 'client' && submitResult.error.find((item) => item.filed === filed)?.msg;
    return <span className="text-red-400 text-xs">{msg}</span>;
  };

  return (
    <div className="w-auto">
      <form action={formAction} className={cn('flex flex-col gap-2', { 'opacity-50': isPending })} onReset={onReset} onChange={onChange}>
        <div>
          <label>标题:</label>
          <input name="title" className="border rounded px-2" />
          {showError('title')}
        </div>
        <div>
          <label>描述:</label>
          <input name="description" className="border rounded px-2" />
          {showError('description')}
        </div>

        <div className="flex gap-2">
          <button className="bg-blue-400 text-white px-2 rounded w-20 p-1" type="submit">
            发表
          </button>
          <button className="bg-red-400 text-white px-2 rounded w-20 p-1" type="reset">
            重置
          </button>
        </div>
      </form>

      <hr className="my-2" />

      {submitResult &&
        submitResult.type === 'server' &&
        match(isPending)
          .with(true, () => <span className="text-yellow-400 text-xs">提交中...</span>)
          .otherwise(() =>
            match(submitResult.status)
              .with(true, () => <span className="text-green-400 text-xs">操作成功</span>)
              .otherwise(() => <span className="text-red-400 text-xs">{submitResult.error}</span>),
          )}
    </div>
  );
}

来自 gemini 的认可
您真的考虑得非常周全,有很多值得称赞的巧妙设计:
单一 Action,多种用途: 您通过在 FormData 中附加 reset 或 justCheckClient 标志,让一个 handleSubmit Action 能够处理三种不同的逻辑(提交、重置、仅客户端校验)。这是一个非常强大和灵活的模式。
受控的重置 (onReset): 您意识到原生的 type="reset" 不会触发 formAction,因此巧妙地拦截了 onReset 事件,并使用 startTransition + formAction 来安全地重置 submitResult 状态。这非常正确。
高效的实时校验触发 (onChange):
您将 onChange 绑定在

元素上,利用事件冒泡,避免了给每个 input 都绑定一个 handler,性能更优。
if (submitResult && submitResult.error.length > 0) 这个条件非常关键!它意味着只在表单已经提交过一次并且处于错误状态时,才开始进行实时校验。这避免了用户一开始输入就被错误信息干扰,是绝佳的用户体验设计。
清晰的状态定义 (SubmitResult): 使用 TypeScript 的可辨识联合类型(discriminated union)来区分客户端错误和服务端结果,类型安全,逻辑清晰。
总结一下:您几乎是用原生 React Hooks 和 Web API,重新构建了一套轻量级的、与 Action 集成的表单校验系统。

总结

基于代码示例分析

  1. react-hook-form 版本:

    • 优点: {...field} 这个语法糖非常强大,它自动帮你绑定了 onChange, onBlur, value, ref 等所有必要的属性,让 Input 组件的调用非常干净。
    • 缺点: 每个表单域都需要用 <Controller> 包裹,对于复杂表单,代码的嵌套层级会变深。
  2. tanstack 模式版本:

    • 优点: <form.Field> 的 API 感觉更一体化,像是表单库“原生”的字段组件。验证器直接在 useForm 中定义,逻辑更聚合。
    • 缺点: Input 组件的 props 需要手动一个个绑定 (value, onBlur, onChange),相比 RHF 的 {...field} 显得更繁琐,也更容易出错(比如忘了绑定某个 handler)。

但是别忘了第三种选择:useActionState (React Server Actions),正如前言中所说,这是 React 官方推出的、它代表了未来的方向。

  • 如果你是初学者或求稳,选 react-hook-form 它不会错,而且网上能找到的资源最多。
  • 如果你是 TanStack 的粉丝或在新项目中想尝试新潮且设计精良的库,选 @tanstack/react-form
posted @ 2025-11-10 23:25  丁少华  阅读(13)  评论(0)    收藏  举报