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

React useFieldArray场景下子组件传值到父组件并求和求助

解决方案

核心思路是把计算所需的coefficient和effort存入react-hook-form的表单字段中,而非子组件的本地状态,这样父组件可以通过watch实时获取所有动态字段的计算参数,自动处理索引变化,再统一求和。

1. 子组件(Requirement)修改

去掉本地的coefficient和effort状态,直接将这两个值绑定到表单字段,同时复用计算函数:

const Requirement = ({
  hourValue,
  fieldId,
  errors,
  index,
  register,
  setCurrentIndex,
  requirementOptions,
  setValue,
  currentIndex,
}: SelectProps) => {

  const requirementValueCalc = (effort: number, factor: number, hourValue: number) => {
    return effort * factor * hourValue;
  };

  // 从表单字段获取当前项的coefficient和effort
  const getCurrentRequirementValues = () => {
    const coefficient = (register(`requirements.${index}.coefficient`).value as unknown as number) || 0;
    const effort = (register(`requirements.${index}.effort`).value as unknown as number) || 0;
    return { coefficient, effort };
  };

  return (
    <>
      <div key={fieldId} className='flex flex-col gap-3'>
        <div className='flex flex-row items-center justify-start gap-10'>
          <div>
            <Input type={'checkbox'}></Input>
          </div>
          <div className='w-2/6'>
            {errors.requirements?.[index].title?.message && (
              <span className='text-xs text-error-900'>
                {errors.requirements?.[index].title?.message}
              </span>
            )}
            <Input
              id='requirementTitle'
              placeholder='Título do requisito'
              withoutBorder
              {...register(`requirements.${index}.title`)}
              onFocus={() => setCurrentIndex(index)}
              className={classNames(
                errors.requirements
                  ? 'border-error-900 px-3 py-2 placeholder-error-900 focus:border-error-900 focus:ring-error-900'
                  : 'border-transparent px-3 py-2 placeholder-dark-300 focus:border-primary-500 focus:ring-0',
                'block w-full appearance-none rounded-md border',
                'bg-dark-900 text-white caret-primary-600',
                'focus:outline-none sm:text-sm'
              )}
            />
          </div>

          <div className='flex w-1/4 gap-3'>
            {requirementOptions.length > 0 && (
              <>
                <Select
                  handleSetValue={(value) => {
                    // 同时设置typeRequirementId、coefficient、effort到表单字段
                    setValue(`requirements.${index}.typeRequirementId`, value.id);
                    setValue(`requirements.${index}.coefficient`, value.coefficient);
                    setValue(`requirements.${index}.effort`, value.effort);
                  }}
                  options={requirementOptions}
                />
                <div className='flex ml-8 w-full gap-3'>
                  {(() => {
                    const { coefficient, effort } = getCurrentRequirementValues();
                    return <Title size={'bold'}>{`R$ ${requirementValueCalc(effort, coefficient, hourValue)}`}</Title>;
                  })()}
                </div>
              </>
            )}
          </div>
        </div>

        <div className='mb-5 flex flex-col gap-2'>
          {errors.requirements?.[index].description?.message && (
            <span className='text-xs text-error-900'>
              {errors.requirements?.[index].description?.message}
            </span>
          )}
          <textarea
            id='description'
            rows={2}
            placeholder='Descrição do requisito'
            className={classNames(
              errors.requirements
                ? 'border-error-900 px-3 py-2 placeholder-error-900 focus:border-error-900 focus:ring-error-900'
                : 'border-transparent px-3 py-2 placeholder-dark-300 focus:border-primary-500 focus:ring-0',
              'block w-full appearance-none rounded-md border',
              'bg-dark-900 text-white caret-primary-600',
              'focus:outline-none sm:text-sm'
            )}
            {...register(`requirements.${index}.description`)}
          />
        </div>

        <hr className={currentIndex ? 'border-dark-300 pb-5' : 'invisible'} />
      </div>
    </>
  );
};

export { Requirement };

2. 父组件(ThirdPage)修改

使用watch监听整个requirements数组,实时计算总和并更新totalValue和totalHours:

import { watch } from 'react-hook-form';

const ThirdPage: ForwardRefRenderFunction<
  HTMLSelectElement,
  ThirdPageProps
> = ({
  formProps: {
    register,
    formState: { errors },
    control,
    setValue,
    setFocus,
    watch,
  },
  formData,
  secondPageData,
}) => {
  const {
    dealId,
    object,
    contractPeriod,
    paymentMethodId,
    dueDay,
    totalHours,
    totalValue,
  } = formData[0];

  const [currentIndex, setCurrentIndex] = useState<number>(0);
  const [requirements, setRequirements] = useState<RequerimentType[]>([]);

  const { append, remove, fields, insert } = useFieldArray({
    name: 'requirements',
    control,
  });

  // 监听requirements数组的变化
  const formRequirements = watch('requirements', []);
  // 实时计算总和
  const [calculatedTotalValue, setCalculatedTotalValue] = useState(totalValue || 0);
  const [calculatedTotalHours, setCalculatedTotalHours] = useState(totalHours || 0);

  useEffect(() => {
    const hourValue = secondPageData.hourValue;
    let newTotalValue = 0;
    let newTotalHours = 0;

    formRequirements.forEach(item => {
      const coeff = item.coefficient || 0;
      const effort = item.effort || 0;
      newTotalValue += effort * coeff * hourValue;
      newTotalHours += effort;
    });

    setCalculatedTotalValue(newTotalValue);
    setCalculatedTotalHours(newTotalHours);
    // 更新表单字段
    setValue('totalValue', newTotalValue);
    setValue('totalHours', newTotalHours);
  }, [formRequirements, secondPageData.hourValue, setValue]);

  const requirementOptions = requirements?.map(
    useCallback(
      (requirement) => {
        return {
          id: requirement.id,
          name: `${requirement.title} (${Math.floor(
            requirement.estimate / 8
          )} dias)`,
          value: requirement.title,
          effort: requirement.estimate,
          coefficient: requirement.coefficient,
        };
      },
      [requirements]
    )
  );

  const handleKeyPress = (event: KeyboardEvent) => {
    if (
      (event.ctrlKey || event.metaKey) &&
      event.key === 'ArrowUp' &&
      currentIndex > 0
    ) {
      insert(currentIndex, {});
    }

    if ((event.ctrlKey || event.metaKey) && event.key === 'ArrowDown') {
      insert(currentIndex + 1, {});
    }
    if (
      (event.ctrlKey || event.metaKey) &&
      event.key === 'Backspace' &&
      currentIndex > 0
    ) {
      remove(currentIndex);
      setFocus(`requirements.${currentIndex - 1}.title`);
    }
  };

  useEffect(() => {
    document.addEventListener('keydown', handleKeyPress);
    return () => {
      document.removeEventListener('keydown', handleKeyPress);
    };
  }, [handleKeyPress]);

  useEffect(() => {
    const getAllRequirements = async () => {
      try {
        const response = await listAllRequirements();
        setRequirements(response.data.items);
      } catch (err) {
        console.log(err.response.data);
      }
    };
    getAllRequirements();
    append({});
  }, []);

  return (
    <>
      <div className='flex flex-col divide-y divide-zinc-700 divide-opacity-25 px-6 last:border-0 sm:px-2'>
        <div className='flex flex-col'>
          <div className='flex flex-row items-center justify-between gap-10'>
            <Input
              placeholder='Id da oportunidade vem do RD'
              label='Id da oportunidade'
              withoutBorder
              error={errors.dealId}
              {...register('dealId')}
              defaultValue={dealId}
            />
            <Input
              placeholder='Objeto'
              label='Objeto'
              withoutBorder
              error={errors.object}
              {...register('object')}
              defaultValue={object}
            />
          </div>

          <div className='flex flex-row items-center justify-between gap-10 pt-6'>
            <Input
              placeholder='Período do contrato'
              label='Período do contrato'
              withoutBorder
              error={errors.contractPeriod}
              {...register('contractPeriod')}
              defaultValue={contractPeriod}
            />
            <Input
              placeholder='Método de pagamento'
              label='Método de pagamento'
              withoutBorder
              error={errors.paymentMethodId}
              {...register('paymentMethodId')}
              defaultValue={paymentMethodId}
              disabled
            />
          </div>

          <div className='flex items-center justify-between gap-10 pt-6'>
            <Input
              placeholder='Dia de vencimento da parcela'
              label='Dia de vencimento da parcela'
              type='number'
              withoutBorder
              error={errors.dueDay}
              {...register('dueDay')}
              defaultValue={dueDay}
            />
            <div className='flex w-full flex-row items-center justify-between gap-10'>
              <div className='flex w-full justify-end'>
                <input
                  label='Total de horas'
                  id='totalHours'
                  type='number'
                  hidden
                  error={errors.totalHours}
                  {...register('totalHours')}
                  defaultValue={calculatedTotalHours}
                />
              </div>
              <div className='flex w-full flex-col items-start gap-2'>
                <input
                  id='totalValue'
                  hidden={true}
                  defaultValue={calculatedTotalValue}
                  {...register('totalValue')}
                />
              </div>
            </div>
          </div>
          <hr className='mt-8 border-dark-300 pt-5' />

          <div className='flex w-full justify-between'>
            <h1 className='font-bold text-primary-600'>Requisitos</h1>
            <Tooltip />
          </div>

          {fields.map((field, index) => (
            <Requirement
              fieldId={field.id}
              errors={errors}
              index={index}
              register={register}
              setCurrentIndex={setCurrentIndex}
              requirementOptions={requirementOptions}
              setValue={setValue}
              currentIndex={currentIndex}
              hourValue={secondPageData.hourValue}
            />
          ))}

          <div className='flex w-full justify-end'>
            <div className='flex-col'>
              <div className='flex justify-end'>
                <h6 className='py-2 text-white'>{`${calculatedTotalHours} / horas`}</h6>
              </div>
              <div className='flex justify-end'>
                <Title size={'bold'}>{`R$ ${calculatedTotalValue}`}</Title>
              </div>
              <div className='flex justify-end'>
                <h6 className='py-2 text-white'>
                  <b>4 sprint</b> / ORÇADO
                </h6>
              </div>
              <div className='flex justify-end'>
                <h6 className='py-2 text-white'>
                  <b>{`${Math.floor(calculatedTotalHours / 8)} dias úteis`}</b> / CONTRATO
                </h6>
              </div>
            </div>
          </div>
        </div>
      </div>
    </>
  );
};

export default ThirdPage;

为什么这样能解决索引问题?

  • react-hook-form的useFieldArray会自动维护动态字段的索引和唯一标识,watch获取到的数组始终是最新结构,包含所有字段的当前值。
  • 不需要手动追踪子组件的索引变化,所有计算依赖表单数据源,天然适配字段的添加、删除、插入操作。
  • 子组件不再依赖本地状态,数据完全由表单管理,避免了父子组件状态不同步的问题。

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

相关产品推荐
方舟 Agent Plan

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

最近更新时间:2026.08.11 08:45:32