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

实际项目中,选一种你喜欢和熟悉的方式去用即可,不过目测第三者会是主流,毕竟是基于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 绑定在

浙公网安备 33010602011771号