You need to enable JavaScript to run this app.
优惠活动
大模型
产品
解决方案
定价
更多

使用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

相关产品推荐
方舟 Agent Plan

超全模态模型 × Harness 升级,最新支持 Deepseek-V4.1-Flash、GLM-5.3 系列、Doubao-Seedream-5.0-pro、Kimi-K3 (部分), 限时 9.9 元起

最近更新时间:2026.06.27 12:52:04