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

如何用Zod与react-hook-form实现多图片上传?解决提交报错问题

React Hook Form + Zod 多文件上传问题解决与最佳实践

问题背景

我正在用React 18构建注册表单,采用react-hook-form和Zod做表单管理。

遇到的问题

我想允许用户上传多个附件,但不知道怎么用Zod处理这个需求。提交表单时遇到报错:Expected array, received object。

现有代码

Zod Schema

const submitSchema = z.object({
    // Applicant company/organization, required
    org_name: z.string().min(1, { message: "Please describe the applicant company/organization." }),

    // Status, optional
    status: z.enum(["public", "private"]),

    // Item picture, required
    images: z
        .array(z.instanceof(File))
        .refine(
            (files) => files.every((file) => file.size <= MAX_FILE_SIZE && ACCEPTED_IMAGE_TYPES.includes(file.type)),
            { 
                message: "Item photo: Only .jpeg, .jpg, .png files of 2MB or less are accepted",
            }
        ),
});

ImagesField 组件

type ImagesFieldProps = {
    className?: string;
};
const ImagesField = ({ className }: ImagesFieldProps) => {
    const { control } = useFormContext<SubmitSchemaType>();
    return (
        <FormField
            label={
                <div className="md:min-w-[150px]">
                    <BrowserView>
                        품목사진<div className="text-sm">(선택사항)</div>
                    </BrowserView>
                    <MobileView>품목사진 (선택사항)</MobileView>
                </div>
            }
            valueContent={
                <div className="file-input-set-container flex flex-col gap-3">
                    <Controller
                        name="images"
                        control={control}
                        render={({ field: { onChange, onBlur, name }, fieldState: { error } }) => (
                            <FileInput
                                name={name}
                                onChange={(e) => onChange([...(e.target.files ?? [])][0])}
                                onDelete={() => onChange(undefined)}
                                onBlur={onBlur}
                                errorMessages={error?.message}
                            />
                        )}
                    />
                    <Controller
                        name="images"
                        control={control}
                        render={({ field: { onChange, onBlur, name }, fieldState: { error } }) => (
                            <FileInput
                                name={name}
                                onChange={(e) => onChange([...(e.target.files ?? [])][0])}
                                onDelete={() => onChange(undefined)}
                                onBlur={onBlur}
                                errorMessages={error?.message}
                            />
                        )}
                    />
                </div>
            }
            className={className}
            noticeMessage="품목사진은 2M이하만 등록 가능합니다."
        />
    );
};

问题原因

报错的核心原因是:

  1. 两个Controller绑定了同一个name="images"字段,每次选择文件时只传入单个文件([...(e.target.files ?? [])][0]),删除时甚至传入undefined,和Zod定义的数组类型不匹配,导致验证失败。
  2. 组件标注图片为“선택사항”(可选),但Zod的images字段是必填数组,未处理undefined的情况。

解决方案

1. 修正Zod Schema

先调整Schema,处理可选性,同时优化验证逻辑:

const MAX_FILE_SIZE = 2 * 1024 * 1024; // 2MB
const ACCEPTED_IMAGE_TYPES = ["image/jpeg", "image/jpg", "image/png"];

const submitSchema = z.object({
    org_name: z.string().min(1, { message: "请填写申请公司/组织名称。" }),
    status: z.enum(["public", "private"]),
    images: z
        .array(z.instanceof(File))
        .optional() // 标记为可选字段
        .default([]) // 或者用default给空数组,避免undefined
        .refine(
            (files) => {
                if (!files) return true; // 可选字段允许undefined
                return files.every(file => ACCEPTED_IMAGE_TYPES.includes(file.type));
            },
            { message: "仅支持 .jpeg, .jpg, .png 格式的图片" }
        )
        .refine(
            (files) => {
                if (!files) return true;
                return files.every(file => file.size <= MAX_FILE_SIZE);
            },
            { message: "每张图片大小不能超过2MB" }
        ),
});

2. 重构ImagesField组件

根据需求选择以下两种方案:

方案一:支持动态添加/删除多文件(推荐)

使用useFieldArray更便捷地管理数组字段:

import { useFormContext, useFieldArray } from 'react-hook-form';

type ImagesFieldProps = {
    className?: string;
};
const ImagesField = ({ className }: ImagesFieldProps) => {
    const { control } = useFormContext<SubmitSchemaType>();
    const { fields, append, remove } = useFieldArray({
        control,
        name: "images"
    });

    return (
        <FormField
            label={
                <div className="md:min-w-[150px]">
                    <BrowserView>
                        품목사진<div className="text-sm">(선택사항)</div>
                    </BrowserView>
                    <MobileView>품목사진 (선택사항)</MobileView>
                </div>
            }
            valueContent={
                <div className="file-input-set-container flex flex-col gap-3">
                    {fields.map((item, index) => (
                        <div key={item.id} className="flex items-center gap-2">
                            <Controller
                                name={`images.${index}`}
                                control={control}
                                render={({ field: { onChange, value }, fieldState: { error } }) => (
                                    <FileInput
                                        onChange={(e) => onChange(Array.from(e.target.files ?? [])[0])}
                                        onDelete={() => remove(index)}
                                        onBlur={() => {}}
                                        errorMessages={error?.message}
                                    />
                                )}
                            />
                        </div>
                    ))}
                    <button 
                        type="button" 
                        className="mt-2 px-4 py-2 border rounded"
                        onClick={() => append(null)}
                    >
                        添加图片
                    </button>
                </div>
            }
            className={className}
            noticeMessage="품목사진은 2M이하만 등록 가능합니다."
        />
    );
};

方案二:固定最多2张图片(适配原组件结构)

如果需要保留两个独立的输入框,修改onChange逻辑维护数组:

type ImagesFieldProps = {
    className?: string;
};
const ImagesField = ({ className }: ImagesFieldProps) => {
    const { control } = useFormContext<SubmitSchemaType>();
    return (
        <FormField
            label={
                <div className="md:min-w-[150px]">
                    <BrowserView>
                        품목사진<div className="text-sm">(선택사항)</div>
                    </BrowserView>
                    <MobileView>품목사진 (선택사항)</MobileView>
                </div>
            }
            valueContent={
                <Controller
                    name="images"
                    control={control}
                    render={({ field: { onChange, value, onBlur }, fieldState: { error } }) => (
                        <div className="file-input-set-container flex flex-col gap-3">
                            <FileInput
                                onChange={(e) => {
                                    const file = Array.from(e.target.files ?? [])[0];
                                    if (file) {
                                        const newImages = [...(value ?? [])];
                                        newImages[0] = file;
                                        onChange(newImages);
                                    }
                                }}
                                onDelete={() => {
                                    const newImages = [...(value ?? [])];
                                    newImages.splice(0, 1);
                                    onChange(newImages);
                                }}
                                onBlur={onBlur}
                                errorMessages={error?.message}
                            />
                            <FileInput
                                onChange={(e) => {
                                    const file = Array.from(e.target.files ?? [])[0];
                                    if (file) {
                                        const newImages = [...(value ?? [])];
                                        newImages[1] = file;
                                        onChange(newImages);
                                    }
                                }}
                                onDelete={() => {
                                    const newImages = [...(value ?? [])];
                                    newImages.splice(1, 1);
                                    onChange(newImages);
                                }}
                                onBlur={onBlur}
                                errorMessages={error?.message}
                            />
                        </div>
                    )}
                />
            }
            className={className}
            noticeMessage="품목사진은 2M이하만 등록 가능합니다."
        />
    );
};

最佳实践

  1. 明确字段可选性:根据业务需求标记字段为optional()或设置default([]),避免类型不匹配。
  2. 拆分验证规则:将文件类型、大小的验证拆分为独立的refine,给出更精准的错误提示,提升用户体验。
  3. 使用useFieldArray管理动态数组:对于需要动态添加/删除文件的场景,useFieldArray比手动维护数组更简洁、不易出错。
  4. 添加预览功能:上传后展示图片预览,允许用户删除对应文件,提升交互体验。
  5. 前端重复校验:可以通过文件名或文件hash检查是否已上传相同文件,避免重复提交。
  6. 结合后端校验:前端验证仅作为用户体验优化,后端必须再次校验文件的类型、大小和内容,确保安全性。

内容的提问来源于stack exchange,提问作者Byeongin Yoon

相关产品推荐
方舟 Agent Plan

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

最近更新时间:2026.07.03 11:03:12