Zod Form
1/27/2026, 1:17:38 PM modified by MarvinA form component that uses Zod for validation.
Install
Preview
'use client';import type React from 'react';import { useCallback, useMemo, useState } from 'react';import { z } from 'zod';import type { IFieldJSONSchema } from './default-components';import ZodField from './zod-field';type ZodSchema = z.ZodObject<Record<string, z.ZodTypeAny>>;interface ZodFormProps<T extends ZodSchema> { schema: T; onSubmit: (data: z.infer<T>) => void; defaultValues?: Partial<z.infer<T>>; className?: string; fieldClassName?: string; children?: React.ReactNode;}export function ZodForm<T extends ZodSchema>(props: ZodFormProps<T>) { const { schema, onSubmit, defaultValues = {}, className = '', fieldClassName = '', children } = props; // 使用 Zod v4 内置的 JSON Schema 转换 const jsonSchema = useMemo(() => z.toJSONSchema(schema), [schema]); // 初始化表单数据 const initialFormData = useMemo(() => { const schemaDefaults = extractDefaultValues(jsonSchema); return { ...schemaDefaults, ...defaultValues }; }, [jsonSchema, defaultValues]); const [formData, setFormData] = useState<any>(initialFormData); const [errors, setErrors] = useState<Record<string, string>>({}); // 更新字段值 const updateField = (name: string, value: any) => { setFormData((prev: any) => ({ ...prev, [name]: value })); }; const errorHandler = useCallback( (args: { error: NonNullable<z.ZodSafeParseResult<unknown>['error']>; name?: string; bypassCallback?: (data: any) => void; }) => { const { name, error } = args; setErrors((prev) => { const newErrors = { ...prev }; console.log('newErrors', newErrors); error.issues.forEach((issue) => { const path = name || issue.path.join('.'); newErrors[path] = issue.message; }); console.log('newErrors', newErrors); return newErrors; }); }, [], ); const validateField = async (name: string, value: any) => { const fieldSchema = schema.shape[name as keyof typeof schema.shape]; if (!fieldSchema) { return true; } const result = fieldSchema.safeParse(value); if (result.success) { setErrors((prev) => { const newErrors = { ...prev }; delete newErrors[name]; return newErrors; }); } else { errorHandler({ error: result.error, name }); } return result.success; }; // 表单提交 const handleSubmit = (e: React.FormEvent) => { e.preventDefault(); const result = schema.safeParse(formData); if (result.success) { setErrors({}); onSubmit(result.data); } else { errorHandler({ error: result.error, }); } }; const handleReset = () => { setFormData({ ...initialFormData }); setErrors({}); }; const fields = useMemo(() => { return Object.entries(jsonSchema.properties || {}) as [string, IFieldJSONSchema][]; }, [jsonSchema]); return ( <form onReset={handleReset} onSubmit={handleSubmit} className={`${className}`}> {fields.map(([name, fieldJsonSchema]) => { if (typeof fieldJsonSchema === 'boolean') return null; const type = fieldJsonSchema.type; if (!type) return null; const { label, description } = fieldJsonSchema; const options = fieldJsonSchema.enum as string[]; return ( <ZodField type={type} label={label} description={description} key={name} name={name} options={options} value={formData[name]} error={errors[name]} onChange={(e) => updateField(name, e.target.value)} onBlur={() => validateField(name, formData[name])} required={jsonSchema.required?.includes(name)} className={fieldClassName} /> ); })} {children} </form> );}function extractDefaultValues(jsonSchema: any): Record<string, any> { const defaults: Record<string, any> = {}; if (jsonSchema.properties) { for (const [key, field] of Object.entries<any>(jsonSchema.properties)) { if (field.default !== undefined) { defaults[key] = field.default; } } } return defaults;}import { memo } from 'react';import type { IFieldProps } from './default-components';import { defaultComponents } from './default-components';function ZodField(props: IFieldProps) { const { name, type, label, description, ...restProps } = props; // 根据类型渲染对应的组件 const FieldComponent = defaultComponents[type]; if (!FieldComponent) return <p className="text-red-500">{type} not found</p>; return <FieldComponent name={name} type={type} label={label || name} description={description} {...restProps} />;}export default memo(ZodField, (prevProps, nextProps) => { return prevProps.error === nextProps.error && prevProps.value === nextProps.value;});'use client';import dynamic from 'next/dynamic';import { z } from 'zod';import { ZodFormFooter, ZodFormReset, ZodFormSubmit } from './default-components';const ZodForm = dynamic(() => import('./zod-form').then((mod) => mod.ZodForm), { ssr: false, loading: () => <div>Loading ZodForm...</div>,});const schema = z.object({ name: z.string().min(1, '姓名不能为空'), email: z.email('请输入有效的邮箱地址'), gender: z.enum(['male', 'female']).meta({ type: 'radio', // 类型级别的映射 }),});export default function Demo() { return ( <ZodForm schema={schema} defaultValues={{ name: 'John Doe', email: 'john.doe@example.com', gender: 'male' }} onSubmit={(data) => console.log('data', data)} > <ZodFormFooter> <ZodFormSubmit>Submit</ZodFormSubmit> <ZodFormReset variant={'destructive'}>Reset</ZodFormReset> </ZodFormFooter> </ZodForm> );}import type { FC, InputHTMLAttributes } from 'react';import type { z } from 'zod';import { Button } from '@/components/ui/button';import { Input } from '@/components/ui/input';import { cn } from '@/lib/utils';export interface IFieldJSONSchema extends z.core.JSONSchema.BaseSchema { component?: string; label?: string;}export type IFieldProps = InputHTMLAttributes<HTMLInputElement> & { type: string; // 不同于 input 的 type,用于组件映射 error?: string; label?: string; name: string; description?: string; options?: string[];};export type NativeComponent = React.ComponentType<IFieldProps>;// ============ 默认组件 ============export const NativeInput: FC<IFieldProps> = (props) => { const { label, required, name, error, value, type: _, ...restProps } = props; return ( <div> <label htmlFor={name}> {label}: {required && <span>*</span>} </label> <Input type="text" name={name} value={value ?? ''} {...restProps} /> {error && <p className="text-red-500 text-sm">{error}</p>} </div> );};export const NativeRadio: FC<IFieldProps> = (props) => { const { label, required, name, error, value, options = [], type: _, ...restProps } = props; return ( <div> <label htmlFor={name}> {label}: {required && <span>*</span>} </label> <div className="flex items-center gap-4"> {options?.map((option) => ( <div key={option} className="flex items-center gap-1"> <input type="radio" id={`${name}_${option}`} name={name} value={option} checked={value === option} {...restProps} /> <label htmlFor={`${name}_${option}`}>{option}</label> </div> ))} </div> {error && <p className="text-red-500 text-sm">{error}</p>} </div> );};export type TComponentType = 'string' | 'checkbox' | 'radio' | 'select';export type TComponentMap = Record<string, NativeComponent> & Partial<Record<TComponentType, NativeComponent>>;export function defineComponents(components: TComponentMap) { return components;}export const defaultComponents = defineComponents({ // 类型级别的映射 string: NativeInput, radio: NativeRadio,});export type ButtonProps = React.ComponentProps<typeof Button>;export function ZodFormSubmit(props: ButtonProps) { return <Button type="submit" {...props} />;}export function ZodFormReset(props: ButtonProps) { return <Button type="reset" {...props} />;}export function ZodFormFooter(props: { className?: string; children: React.ReactNode }) { const { className, children } = props; return <div className={cn('flex justify-end gap-2', className)}>{children}</div>;}API Reference
ZodForm
主要的表单组件,基于 Zod schema 自动生成表单字段。组件使用 Zod v4 的 z.toJSONSchema() 将 schema 转换为 JSON Schema,然后根据字段类型自动渲染对应的输入组件。
Props
| 属性 | 类型 | 默认值 | 描述 |
|---|---|---|---|
schema | ZodObject<Record<string, ZodTypeAny>> | - | 必需。Zod schema 对象,定义表单结构和验证规则 |
onSubmit | (data: z.infer<T>) => void | - | 必需。表单提交时的回调函数 |
defaultValues | Partial<z.infer<T>> | {} | 表单的默认值,会与 schema 中的 default 值合并 |
className | string | "" | 表单容器的 CSS 类名 |
fieldClassName | string | "" | 字段容器的 CSS 类名 |
children | React.ReactNode | - | 表单子元素,通常用于添加提交按钮等 |
特性
- 自动字段生成:根据 Zod schema 自动生成表单字段
- 实时验证:字段失焦时自动验证(
onBlur) - 类型安全:完整的 TypeScript 类型支持
- 错误处理:自动显示验证错误信息
- 表单重置:支持通过
type="reset"按钮重置表单
Example
import {
ZodForm,
ZodFormFooter,
ZodFormSubmit,
ZodFormReset,
} from "@/registry/new-york/components/zod-form";
import { z } from "zod";
const userSchema = z.object({
name: z.string().min(1, "姓名不能为空"),
email: z
.string()
.email("请输入有效的邮箱地址")
.meta({
type: "string", // 指定组件类型
label: "邮箱地址", // 字段标签
description: "请输入您的邮箱地址", // 字段描述
}),
});
function UserRegistrationForm() {
const handleSubmit = (data: z.infer<typeof userSchema>) => {
console.log("用户数据:", data);
// 处理注册逻辑
};
return (
<ZodForm
schema={userSchema}
onSubmit={handleSubmit}
defaultValues={{
name: "",
email: "",
}}
className="max-w-md mx-auto space-y-4"
fieldClassName="space-y-2"
>
<ZodFormFooter>
<ZodFormSubmit>提交</ZodFormSubmit>
<ZodFormReset variant="outline">重置</ZodFormReset>
</ZodFormFooter>
</ZodForm>
);
}字段配置
使用 z.meta() 可以为字段添加额外的配置信息:
const schema = z.object({
email: z
.string()
.email("请输入有效的邮箱地址")
.meta({
type: "string", // 组件类型,用于映射到对应的输入组件
label: "邮箱地址", // 字段标签,如果不提供则使用字段名
description: "请输入您的邮箱地址", // 字段描述
}),
});注意:
type字段用于组件类型映射,当前支持string类型label如果不提供,会使用字段名作为标签default值可以在 schema 中通过z.string().default("默认值")设置,或在defaultValuesprop 中提供
ZodField
单个表单字段组件,根据 type 类型从 defaultComponents 中获取对应的输入组件进行渲染。该组件使用 React.memo 进行性能优化,只在 error 或 value 变化时重新渲染。
注意:ZodField 通常由 ZodForm 内部使用,不需要直接使用。如果需要自定义字段渲染,可以通过自定义组件映射来实现。
Props
| 属性 | 类型 | 默认值 | 描述 |
|---|---|---|---|
name | string | - | 必需。字段名称 |
label | string | - | 字段标签,如果不提供则使用 name |
description | string | - | 字段描述 |
value | any | - | 字段值 |
error | string | - | 错误信息 |
required | boolean | - | 是否为必填字段 |
onChange | React.ChangeEventHandler | - | 值变化时的回调函数 |
onBlur | React.FocusEventHandler | - | 失焦时的回调函数,通常用于触发验证 |
className | string | - | 字段容器的 CSS 类名 |
字段配置 (IFieldJSONSchema)
通过 z.meta() 可以配置以下字段属性:
| 属性 | 类型 | 描述 |
|---|---|---|
type | string | 指定使用的组件类型(可选,默认使用 type) |
type | string | 字段类型,通常会自动推断,用于组件映射(string, number, boolean 等) |
label | string | 字段标签,如果不提供则使用字段名 |
description | string | 字段描述 |
default | any | 默认值,也可以通过 z.string().default("值") 设置 |
自定义组件
defineComponents
使用 defineComponents 函数可以定义自定义的组件映射,用于覆盖或扩展默认组件。
import { defineComponents, NativeInput, type NativeComponent } from "@/registry/new-york/components/zod-form";
// 自定义邮箱输入组件
const EmailInput: NativeComponent = (props) => {
const { label, required, name, error, value, ...restProps } = props;
return (
<div>
<label htmlFor={name}>
{label}: {required && <span>*</span>}
</label>
<Input
type="email"
name={name}
value={value ?? ''}
placeholder="example@email.com"
{...restProps}
/>
{error && <p className="text-red-500 text-sm">{error}</p>}
</div>
);
};
// 定义组件映射
const customComponents = defineComponents({
string: NativeInput, // 默认字符串输入
email: EmailInput, // 自定义邮箱输入
});
// 在 schema 中使用
const schema = z.object({
email: z.string().email().meta({
type: "email", // 使用自定义的 email 组件
}),
});注意:当前版本的 ZodForm 组件内部使用 defaultComponents,自定义组件映射功能需要在组件内部实现支持。如需使用自定义组件,建议直接修改 default-components.tsx 文件。
Helper Components
ZodFormSubmit
表单提交按钮组件,基于 Button 组件,自动设置 type="submit"。
<ZodFormSubmit>提交</ZodFormSubmit>
<ZodFormSubmit variant="destructive" size="lg">删除</ZodFormSubmit>支持所有 Button 组件的 props。
ZodFormReset
表单重置按钮组件,基于 Button 组件,自动设置 type="reset"。
<ZodFormReset>重置</ZodFormReset>
<ZodFormReset variant="outline">清空</ZodFormReset>支持所有 Button 组件的 props。点击重置按钮会将表单数据重置为 defaultValues 或 schema 中定义的默认值。
ZodFormFooter
表单底部容器组件,用于包装提交和重置按钮。默认使用 flex justify-end gap-2 样式。
<ZodFormFooter className="flex justify-between">
<ZodFormReset>重置</ZodFormReset>
<ZodFormSubmit>提交</ZodFormSubmit>
</ZodFormFooter>高级用法
表单验证
表单支持两种验证方式:
- 字段级验证:字段失焦时(
onBlur)自动验证单个字段 - 表单级验证:提交时验证整个表单
const schema = z.object({
username: z.string().min(3, "用户名至少3个字符"),
email: z.string().email("请输入有效邮箱"),
});
// 字段失焦时会自动验证
// 提交时会验证整个表单,如果有错误会显示所有错误信息复杂验证规则
使用 Zod 的 refine 或 superRefine 可以实现复杂的验证逻辑:
const schema = z
.object({
password: z.string().min(8, "密码至少8个字符"),
confirmPassword: z.string(),
})
.refine((data) => data.password === data.confirmPassword, {
message: "密码不匹配",
path: ["confirmPassword"], // 错误信息会显示在 confirmPassword 字段
});默认值设置
可以通过两种方式设置默认值:
- 在 schema 中设置:
const schema = z.object({
name: z.string().default("默认名称"),
age: z.number().default(18),
});- 通过 defaultValues prop:
<ZodForm
schema={schema}
onSubmit={handleSubmit}
defaultValues={{
name: "自定义名称",
age: 20,
}}
/>defaultValues 会覆盖 schema 中的默认值。
类型定义
INativeInputProps
自定义输入组件需要实现的 props 接口:
export type INativeInputProps = InputHTMLAttributes<HTMLInputElement> & {
error?: string;
label?: string;
name: string;
description?: string;
};NativeComponent
自定义组件的类型定义:
export type NativeComponent = React.ComponentType<INativeInputProps>;IFieldJSONSchema
字段的 JSON Schema 配置接口,扩展了 Zod 的 BaseSchema:
export interface IFieldJSONSchema extends z.core.JSONSchema.BaseSchema {
type?: string;
label?: string;
}常见问题
1. 为什么 reset 后输入框的值没有清空?
确保在组件中正确处理 undefined 值。受控组件需要将 undefined 转换为空字符串:
<Input value={value ?? ''} />2. 如何自定义字段的样式?
可以通过 fieldClassName prop 为所有字段添加统一的样式,或者通过自定义组件来实现特定字段的样式。
3. 如何实现异步验证?
当前版本支持同步验证。如需异步验证,可以在 onSubmit 回调中进行异步操作,或修改 validateField 函数以支持异步验证。
4. 如何禁用某些字段?
可以通过自定义组件来实现禁用功能,或者在 schema 中使用条件验证来控制字段的可用性。
5. 支持哪些字段类型?
当前版本主要支持 string 类型。
可以通过扩展 default-components.tsx 文件中的 defaultComponents 来支持更多类型(如 number、boolean、date 等)。
Git Commit History(1 commits)
fix: dep
b6e70fbd