使用React Hook Form、Zod和Ant Design无法获取期望表单数据
问题:React Hook Form + Zod + Ant Design 表单验证失败(transport_id 必填错误)
我使用react-hook-form、zod和ant-design构建了一个表单,期望提交时得到如下结构的数据:
const formData = { subject_travel: "yes", transport_percentages: [ { transport_id: 1, percentage: 20, }, { transport_id: 2, percentage: 40, }, { transport_id: 3, percentage: 40, }, ], do_you_know_avg_distance_travelled: "no", };
但提交表单时,出现以下验证错误:
{ "transport_percentages": [ { "transport_id": { "message": "Required", "type": "invalid_type" } }, { "transport_id": { "message": "Required", "type": "invalid_type" } }, { "transport_id": { "message": "Required", "type": "invalid_type" } } ] }
相关代码
主表单组件代码
import { type SubmitHandler } from "react-hook-form"; import { z } from "zod"; import useCalulatorStore from "@/store/useCalulatorStore"; import Form from "./form/Form"; import { RadioGroupField } from "./form/fields/RadioGroupField"; import { Col, Row } from "antd"; import { InputNumberField } from "./form/fields/InputNumberField"; import { invalidNumberErrorMessage, percentageField, } from "./form/supply/schema"; const mockData = [ { id: 1, name: "car", }, { id: 2, name: "bike", }, { id: 3, name: "plane", }, ]; const zodTransportPercentage = z.object({ transport_id: z.number().nonnegative(), percentage: percentageField, }); const subject_travel = z.discriminatedUnion("subject_travel", [ z.object({ subject_travel: z.literal("yes"), transport_percentages: z.array(zodTransportPercentage), }), z.object({ subject_travel: z.literal("no"), }), ]); const do_you_know_avg_distance_travelled = z.discriminatedUnion( "do_you_know_avg_distance_travelled", [ z.object({ do_you_know_avg_distance_travelled: z.literal("yes"), avg_distance: z .number({ invalid_type_error: invalidNumberErrorMessage }) .nonnegative(), }), z.object({ do_you_know_avg_distance_travelled: z.literal("no"), }), ] ); const subject_travel_schema = subject_travel.and( do_you_know_avg_distance_travelled ); export type UserDataEntrySubjectTravelData = z.infer< typeof subject_travel_schema >; const UserDataEntrySubjectTravel = () => { const { setData, stepUserDataEntrySubjectTravel } = useCalulatorStore(); const onSubmit: SubmitHandler<UserDataEntrySubjectTravelData> = async ( data ) => { setData({ step: 8, data }); console.log(data); alert(JSON.stringify(data)); }; return ( <> <Form<UserDataEntrySubjectTravelData> zodSchema={subject_travel_schema} onSubmit={onSubmit} defaultValues={stepUserDataEntrySubjectTravel || undefined} > {({ watch, control }) => { const isSubjectTravelYes = watch("subject_travel") === "yes"; const isTravelDistanceYes = watch("do_you_know_avg_distance_travelled") === "yes"; return ( <> <RadioGroupField control={control} name="subject_travel" label="Do you have an average subject travel survey data?" options={[ { label: "Yes", value: "yes" }, { label: "No", value: "no" }, ]} /> {isSubjectTravelYes && ( <Row gutter={20}> {mockData.map((t, i) => ( <Col> <InputNumberField control={control} label={t.name} name={`transport_percentages.${i}.percentage`} key={t.id} /> </Col> ))} </Row> )} <RadioGroupField control={control} name="do_you_know_avg_distance_travelled" label="Do you know the average distance travelled?" options={[ { label: "Yes", value: "yes" }, { label: "No", value: "no" }, ]} /> {isTravelDistanceYes && ( <InputNumberField control={control} label="Distance" name="avg_distance" /> )} </> ); }} </Form> </> ); }; export default UserDataEntrySubjectTravel;
自定义Form组件代码
import type { FormProps as FormAntDProps } from "antd"; import { z } from "zod"; import { zodResolver } from "@hookform/resolvers/zod"; import React from "react"; import { FieldValues, useForm, UseFormReturn, SubmitHandler, FormProvider, DefaultValues, } from "react-hook-form"; import { Button, Form as FormAntD } from "antd"; type FormAntDPropsWithoutChildren = Omit<FormAntDProps, "children">; // Form type FormProps<TFormValues extends FieldValues> = FormAntDPropsWithoutChildren & { onSubmit: SubmitHandler<TFormValues>; children: (methods: UseFormReturn<TFormValues>) => React.ReactNode; // eslint-disable-next-line @typescript-eslint/no-explicit-any zodSchema: z.Schema<any>; buttonText?: string; defaultValues?: DefaultValues<TFormValues>; }; // eslint-disable-next-line @typescript-eslint/no-explicit-any const Form = <TFormValues extends Record<string, any> = Record<string, any>>({ onSubmit, children, zodSchema, buttonText, defaultValues, ...rest }: FormProps<TFormValues>) => { const methods = useForm<TFormValues>({ resolver: zodResolver(zodSchema), defaultValues, }); return ( <> <FormProvider {...methods}> <FormAntD onFinish={methods.handleSubmit(onSubmit)} labelCol={rest.labelCol ?? { span: 24 }} > {children(methods)} <br /> <FormAntD.Item> <Button type="primary" htmlType="submit"> {buttonText ?? "Submit"} </Button> </FormAntD.Item> <pre>{JSON.stringify(methods.formState.errors, null, 2)}</pre> </FormAntD> </FormProvider> </> ); }; export default Form;
InputNumberField组件代码
import { Form, FormItemProps, InputNumber } from "antd"; import { InputNumberProps } from "antd/lib"; import { ReactNode } from "react"; import { Control, Controller, FieldPath, FieldValues } from "react-hook-form"; type InputNumberFieldProps<TFieldValues extends FieldValues = FieldValues> = InputNumberProps & { control: Control<TFieldValues>; name: FieldPath<TFieldValues>; label: ReactNode; customHelp?: string; formItemProps?: FormItemProps; fullWidth?: boolean; }; export const InputNumberField = < TFieldValues extends FieldValues = FieldValues, >({ name, label, control, customHelp, formItemProps, fullWidth = true, ...props }: InputNumberFieldProps<TFieldValues>) => { return ( <Controller name={name} control={control} render={({ field, fieldState }) => { const { onChange, value, onBlur } = field; const { error } = fieldState; return ( <Form.Item {...formItemProps} htmlFor={name} label={label} validateStatus={error ? "error" : "validating"} help={error ? error?.message : customHelp || undefined} > <InputNumber {...props} id={name} value={value} onChange={onChange} onBlur={onBlur} {...(fullWidth && { style: { width: "100%" } })} /> </Form.Item> ); }} /> ); };
解决方案
问题根源
Zod schema要求transport_percentages数组的每个对象必须包含transport_id字段,但当前表单仅渲染了percentage输入框,未将transport_id存入表单数据,导致验证不通过。
修复方式
方式1:通过默认值预设transport_id
在主组件的Form中初始化transport_percentages的默认值,把mockData中的id填充进去:
<Form<UserDataEntrySubjectTravelData> zodSchema={subject_travel_schema} onSubmit={onSubmit} defaultValues={{ ...stepUserDataEntrySubjectTravel, transport_percentages: mockData.map(t => ({ transport_id: t.id, percentage: 0 })) } || undefined} >
方式2:添加隐藏字段存储transport_id
在渲染每个InputNumberField时,额外添加一个隐藏的Controller组件,将mockData的id作为transport_id的值存入表单:
{isSubjectTravelYes && ( <Row gutter={20}> {mockData.map((t, i) => ( <Col key={t.id}> {/* 隐藏字段存储transport_id */} <Controller control={control} name={`transport_percentages.${i}.transport_id`} defaultValue={t.id} render={() => null} /> <InputNumberField control={control} label={t.name} name={`transport_percentages.${i}.percentage`} /> </Col> ))} </Row> )}
说明
两种方式都能满足需求,推荐方式2,无需依赖defaultValues的初始化逻辑,更灵活适配动态数据场景。
内容的提问来源于stack exchange,提问作者Danny Adams
相关产品推荐
相关产品推荐

