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

React表单验证问题:子组件错误未同步至父组件表单状态

React表单验证异常:子组件设置错误后父组件formState.isValid未同步

项目设置概述

  • 表单核心逻辑在父组件实现
  • 验证错误的处理与设置在子组件完成
  • 子组件存在错误时,父组件formState.isValid仍显示为有效

已执行的排查操作

  • 控制台日志确认子组件已正确设置错误
  • 验证错误值已通过props从子组件传递到父组件
  • 确认父组件状态已更新为正确的错误值
  • 检查父组件表单验证逻辑,确认其正确调用错误值

预期结果

当子组件存在错误并传递至父组件时,父组件表单状态应识别这些错误并标记表单为无效,但目前表单状态未受影响。

相关代码

import React, { useEffect } from 'react'
import { Form, Col } from 'react-bootstrap'
import { FormProvider, Controller, useFormContext } from 'react-hook-form'
import Attachments from '../../../../../components/Attachments'
import { DOE_SOURCE, BCM_REPORT_TYPE, COMMON_ROLES, TASK_NAME } from '../../../../../constants/appConstants'
import { IUiConfigurations } from '../../../../../constants/commonInterface'
import { IBcmKpi, getColorForCompliance } from '../KpiReport.dto'
import { ATTACHMENT_CONFIG } from '../../../../../constants/appAttachments'
import { restrictToTwoDecimals } from '../../../../../helpers/api/helper'
import { get } from 'lodash'
import { useSelector } from 'react-redux'

interface IbcmKpiProps {
    taskName?:string | null,
    bcmKpi: IBcmKpi,
    kpiIndex: number,
    registerLocation: any,
    useFormMethods: any,
    uiConfiguration: IUiConfigurations
    jsonLocation: any,
    calculateKpiResultScoreCompliance: any
    calculateApplicableWeight: any
    disableFields: boolean
    setIsUploadedRequiredAttachments: any,
}

export const Kpi = ({ taskName,bcmKpi, kpiIndex, registerLocation, useFormMethods, uiConfiguration, jsonLocation, calculateKpiResultScoreCompliance, calculateApplicableWeight, disableFields, setIsUploadedRequiredAttachments }: IbcmKpiProps) => {
    const watchNotApplicableValue = useFormMethods.watch(`${registerLocation}.notApplicable`);
    const userInfo: any = useSelector((state: any) => state.userDetails);
    
    const calculateApplicantScore = (action?: string) => {
        const modifiedString = registerLocation.replace(/\.performanceIndicatorValue$/, '');
        const deducatableKpiWeight = parseFloat(useFormMethods.getValues(`${modifiedString}.kpiWeight`))
        calculateApplicableWeight(deducatableKpiWeight, action)
    }

    useEffect(() => {
        if (watchNotApplicableValue) {
            useFormMethods.setValue(`${registerLocation}.numerator`, '')
            useFormMethods.setValue(`${registerLocation}.denominator`, '')
            useFormMethods.setValue(`${registerLocation}.compliance`, '')
            useFormMethods.trigger(`${registerLocation}.compliance`)
            calculateApplicantScore('sub')
        }
        else if (watchNotApplicableValue == null) {
            calculateApplicantScore()
        }
        else if (watchNotApplicableValue == false) {
            calculateApplicantScore('add')
        }
    }, [watchNotApplicableValue]);

    return (
        <React.Fragment key={kpiIndex + (bcmKpi.kpiName || "")}>
            <tr >
                <td colSpan={2} className="text-center align-middle"> {bcmKpi?.kpiId}</td>
                <td className="text-center align-middle"> {bcmKpi?.kpiName}</td>
                <td className="text-center align-middle"> {bcmKpi?.kpiWeight}</td>
                <td className='align-middle text-center'>
                    <Form.Check
                        type="checkbox"
                        id="checkboxId"
                        {...useFormMethods.register(`${registerLocation}.notApplicable`)}
                        disabled={disableFields || (!useFormMethods.getValues(`${registerLocation}.comments`) && taskName !== TASK_NAME.SUBMIT_KPI)}
                    />
                </td>
                <td className='align-middle text-center w-20'>
                    <FormProvider {...useFormMethods}>
                        <Col md={12}>
                            <NumeratorInput disableFields={disableFields || (!useFormMethods.getValues(`${registerLocation}.comments`) && taskName !== TASK_NAME.SUBMIT_KPI)} calculateApplicableWeight={calculateApplicableWeight} calculateKpiResultScoreCompliance={calculateKpiResultScoreCompliance} registerLocation={registerLocation} jsonLocation={jsonLocation} uiConfiguration={uiConfiguration} />
                        </Col>
                        <Col md={12}>
                            <DenominatorInput disableFields={disableFields || (!useFormMethods.getValues(`${registerLocation}.comments`) && taskName !== TASK_NAME.SUBMIT_KPI)} registerLocation={registerLocation} jsonLocation={jsonLocation} uiConfiguration={uiConfiguration} />
                        </Col>
                    </FormProvider>
                </td>
                <td className='align-middle'>
                    <Col md={12}>
                        <p>{bcmKpi?.numeratorDescription}</p>
                    </Col>
                    <Col md={12}>
                        <p>{bcmKpi?.denominatorDescription}</p>
                    </Col>
                </td>
                <td className='align-middle text-center'>
                    {useFormMethods.getValues(`${registerLocation}.kpiResult`)}
                </td>
                <td className='align-middle text-center'>
                    {bcmKpi?.performanceIndicatorValue?.score}
                </td>
                <td className='align-middle text-center' style={{ backgroundColor: getColorForCompliance(bcmKpi?.performanceIndicatorValue?.compliance as string) }}>
                    {bcmKpi?.performanceIndicatorValue?.compliance}
                </td>
                <td className='align-middle text-center max-w-25 min-w-25' >
                    <Attachments
                        setIsUploadedRequiredAttachments={setIsUploadedRequiredAttachments}
                        isFieldAttachments={true}
                        source={DOE_SOURCE}
                        type={BCM_REPORT_TYPE}
                        attachmentID={ATTACHMENT_CONFIG.KPI_REPORT}
                        files={useFormMethods.getValues(`${registerLocation}.attachments`) || []}
                        requestId={useFormMethods.getValues(`${registerLocation}.attachments`)[0]?.requestId}
                        readOnly={disableFields || (!useFormMethods.getValues(`${registerLocation}.comments`) && taskName !== TASK_NAME.SUBMIT_KPI)}
                    />
                    <small>
                        <ErrorMessage
                            className="text-danger error-message"
                            errors={useFormMethods.formState.errors}
                            name={`attachments`}
                            as="p"
                        />
                    </small>
                </td>
                <td className='align-middle text-center w-25'>
                    <Controller
                        name={`${registerLocation}.justification`}
                        control={useFormMethods.control}
                        render={({ field: { onChange, value } }) => (
                            <>
                                <Form.Control id={(`${registerLocation}`).toString()} disabled={disableFields || (!useFormMethods.getValues(`${registerLocation}.comments`) && taskName !== TASK_NAME.SUBMIT_KPI)} value={value || null} type="text" as="textarea" rows={3} className={`form-control .${useFormMethods.getFieldState(`${registerLocation}.justification`).error ? 'is-invalid' : ''}`} placeholder={uiConfiguration?.UI_LABELS?.JUSTIFICATION || 'Justification'} onChange={onChange} />
                                <small>
                                    <ErrorMessage
                                        className="text-danger error-message"
                                        errors={useFormMethods.formState.errors}
                                        name={`${registerLocation}.justification`}
                                        as="p"
                                    />
                                </small>
                            </>
                        )} />
                </td>
               { (userInfo?.role?.split(",")?.includes(COMMON_ROLES.BCM_REVIEWER) || taskName === TASK_NAME.BCM_MORE_INFO_BY_REVIEWER )&& <td className='align-middle text-center w-25'>
                    <Controller
                        name={`${registerLocation}.comments`}
                        control={useFormMethods.control}
                        render={({ field: { onChange, value } }) => (
                            <>
                                <Form.Control id={(`${registerLocation}`).toString()} disabled={!userInfo?.role?.split(",")?.includes(COMMON_ROLES.BCM_REVIEWER)} value={value || null} type="text" as="textarea" rows={3} className={`form-control .${useFormMethods.getFieldState(`${registerLocation}.comments`).error ? 'is-invalid' : ''}`} placeholder={uiConfiguration?.UI_LABELS?.COMMENTS || 'Comments'} onChange={onChange} />
                                <small>
                                    <ErrorMessage
                                        className="text-danger error-message"
                                        errors={useFormMethods.formState.errors}
                                        name={`${registerLocation}.comments`}
                                        as="p"
                                    />
                                </small>
                            </>
                        )} />
                </td>}
                <td colSpan={12} ></td>
            </tr>
        </React.Fragment>
    )
}

interface INumeratorAndDenominatorInput {
    calculateKpiResultScoreCompliance?: any,
    registerLocation: any,
    jsonLocation: any,
    uiConfiguration: IUiConfigurations,
    calculateApplicableWeight?: any,
    disableFields: boolean
}

const NumeratorInput = ({ calculateKpiResultScoreCompliance, registerLocation, jsonLocation, uiConfiguration, calculateApplicableWeight, disableFields }: INumeratorAndDenominatorInput) => {
    const useFormMethods = useFormContext();
    const watchNumerator = useFormMethods.watch(`${registerLocation}.numerator`);
    const watchDenominator = useFormMethods.watch(`${registerLocation}.denominator`);
    const watchNotApplicableValue = useFormMethods.watch(`${registerLocation}.notApplicable`);

    useEffect(() => {
        checkNumeratorandDenominatorValidation(registerLocation)
        calculateKpiResultScoreCompliance(`${jsonLocation}`);
    }, [watchNumerator, watchDenominator]);

    const checkNumeratorandDenominatorValidation = (registerLocation: any) => {
        const numerator = parseFloat(watchNumerator);
        const denominator = parseFloat(watchDenominator);
        if (denominator == 0) {
            useFormMethods.setError(`${registerLocation}.denominator`, {
                type: 'manual',
                message: 'Denominator cannot be zero',
            });
        } else if (numerator > denominator) {
            useFormMethods.setError(`${registerLocation}.numerator`, {
                type: 'manual',
                message: 'Numerator cannot be greater than Denominator',
            });
        }
        else {
            useFormMethods.clearErrors(`${registerLocation}.denominator`);
            useFormMethods.clearErrors(`${registerLocation}.numerator`);
        }
        useFormMethods.trigger(`${registerLocation}.denominator`);
        useFormMethods.trigger(`${registerLocation}.numerator`);
        console.log(useFormMethods.formState.errors, useFormMethods.getFieldState(`${registerLocation}.numerator`).error, useFormMethods.formState.isValid)
    }

    return (
        <>
            <Controller key={`${registerLocation}.numerator`}
                name={`${registerLocation}.numerator`}
                control={useFormMethods.control}
                render={({ field: { onChange, value } }) => (
                    <>
                        <Form.Control id={(`${registerLocation}`).toString()} value={value || ''} disabled={watchNotApplicableValue || disableFields} type="number" className={`form-control .${useFormMethods.getFieldState(`${registerLocation}.numerator`).error ? 'is-invalid' : ''}`} placeholder={uiConfiguration?.UI_LABELS?.NUMERATOR || 'Numerator'} onChange={(e) => { e.target.value = restrictToTwoDecimals(e.target.value); onChange(e.target.value); }} />
                        <small>
                            <ErrorMessage
                                className="text-danger error-message"
                                errors={useFormMethods.formState.errors}
                                name={`${registerLocation}.numerator`}
                                as="p"
                            />
                        </small>
                    </>
                )} />
        </>
    )
}

const DenominatorInput = ({ registerLocation, uiConfiguration, disableFields }: INumeratorAndDenominatorInput) => {
    const useFormMethods = useFormContext();
    const watchNotApplicableValue = useFormMethods.watch(`${registerLocation}.notApplicable`);

    return (
        <>
            <Controller key={`${registerLocation}.denominator`}
                name={`${registerLocation}.denominator`}
                control={useFormMethods.control}
                render={({ field: { onChange, value } }) => (
                    <>
                        <Form.Control id={(`${registerLocation}`).toString()} value={value || ''} disabled={watchNotApplicableValue || disableFields} type="number" className={`form-control .${useFormMethods.getFieldState(`${registerLocation}.denominator`).error ? 'is-invalid' : ''}`} placeholder={uiConfiguration?.UI_LABELS?.DENOMINATOR || 'Denominator'} onChange={(e) => { e.target.value = restrictToTwoDecimals(e.target.value); onChange(e.target.value); }} />
                        <small>
                            <ErrorMessage
                                className="text-danger error-message"
                                errors={useFormMethods.formState.errors}
                                name={`${registerLocation}.denominator`}
                                as="p"
                            />
                        </small>
                    </>
                )} />
        </>
    )
}

问题分析与解决方案

核心原因

react-hook-form的formState.isValid是衍生状态,仅当所有注册字段验证通过时才会返回true。你当前手动调用setError后,仅更新了单个字段的错误状态,但未触发表单整体的验证状态同步,导致isValid未更新。此外代码中存在字段禁用时错误未清除、trigger调用范围过小等细节问题。

具体修复步骤

1. 触发整体验证更新isValid

修改checkNumeratorandDenominatorValidation函数,在设置/清除错误后调用无参数的trigger(),触发所有字段验证以同步表单状态:

const checkNumeratorandDenominatorValidation = (registerLocation: any) => {
    const numerator = parseFloat(watchNumerator);
    const denominator = parseFloat(watchDenominator);
    const isFieldDisabled = watchNotApplicableValue || disableFields;

    // 字段禁用时清除错误,不影响表单有效性
    if (isFieldDisabled) {
        useFormMethods.clearErrors(`${registerLocation}.denominator`);
        useFormMethods.clearErrors(`${registerLocation}.numerator`);
        useFormMethods.trigger();
        return;
    }

    if (denominator == 0) {
        useFormMethods.setError(`${registerLocation}.denominator`, {
            type: 'manual',
            message: 'Denominator cannot be zero',
        });
    } else if (numerator > denominator) {
        useFormMethods.setError(`${registerLocation}.numerator`, {
            type: 'manual',
            message: 'Numerator cannot be greater than Denominator',
        });
    } else {
        useFormMethods.clearErrors(`${registerLocation}.denominator`);
        useFormMethods.clearErrors(`${registerLocation}.numerator`);
    }
    // 触发整体验证,同步formState.isValid
    useFormMethods.trigger();
}

2. 同步禁用字段的错误状态

在Kpi组件监听notApplicable状态时,添加清除对应字段错误的逻辑:

useEffect(() => {
    if (watchNotApplicableValue) {
        useFormMethods.setValue(`${registerLocation}.numerator`, '')
        useFormMethods.setValue(`${registerLocation}.denominator`, '')
        useFormMethods.setValue(`${registerLocation}.compliance`, '')
        // 清除字段错误,避免禁用状态下错误影响表单有效性
        useFormMethods.clearErrors(`${registerLocation}.numerator`);
        useFormMethods.clearErrors(`${registerLocation}.denominator`);
        useFormMethods.trigger(`${registerLocation}.compliance`)
        calculateApplicantScore('sub')
    }
    // ... 其他逻辑
}, [watchNotApplicableValue]);

3. 移除多余的FormProvider嵌套

Kpi组件中已通过props传递useFormMethods,子组件通过useFormContext即可获取上下文,无需额外嵌套FormProvider,直接删除该嵌套:

<td className='align-middle text-center w-20'>
    <Col md={12}>
        <NumeratorInput 
            disableFields={disableFields || (!useFormMethods.getValues(`${registerLocation}.comments`) && taskName !== TASK_NAME.SUBMIT_KPI)} 
            calculateApplicableWeight={calculateApplicableWeight} 
            calculateKpiResultScoreCompliance={calculateKpiResultScoreCompliance} 
            registerLocation={registerLocation} 
            jsonLocation={jsonLocation} 
            uiConfiguration={uiConfiguration} 
        />
    </Col>
    <Col md={12}>
        <DenominatorInput 
            disableFields={disableFields || (!useFormMethods.getValues(`${registerLocation}.comments`) && taskName !== TASK_NAME.SUBMIT_KPI)} 
            registerLocation={registerLocation} 
            jsonLocation={jsonLocation} 
            uiConfiguration={ui
相关产品推荐
方舟 Agent Plan

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

最近更新时间:2026.07.05 23:56:38