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
相关产品推荐
相关产品推荐

