How to validate section in multi step form?

ghz 7months ago ⋅ 87 views

I have a wizard form with the structure:

{
  contactInfo:{
    name: "string",
    email: "string",
    ...
  }
  petInfo: {
    petName: "string",
    petBreed: "string,
    ...
  }
  vetInfo: {
    vetName: "string",
    vetPhone: "string",
    ...
  }
}

At the top level of the component I render each section:

// Form Validation
import { yupResolver } from "@hookform/resolvers/yup";
import schema from "utils/FormValidation/wizardFormValidation";
import { defaultValues } from "utils/FormValidation/packFormValidation";

function getSteps(): string[] {
  return ["Contact", "Emergency", "Pack", "Vet"];
}

function getStepContent(stepIndex: number): JSX.Element {
  switch (stepIndex) {
    case 0:
      return <Contact />;
    case 1:
      return <Emergency />;
    case 2:
      return <Pack />;
    case 3:
      return <Vet />;
    default:
      return null;
  }
}

function Welcome(): JSX.Element {
  const dispatch = useAppDispatch();
  const petParent = useAppSelector(selectPetParent);
  const pack = useAppSelector(selectPack);
  const wizard = useAppSelector((state) => state.forms.wizard);
  const values = {
    ...petParent,
    pack: pack.length !== 0 ? [...pack] : [...defaultValues],
  } as Wizard;
  const [activeStep, setActiveStep] = useState<number>(0);
  const steps = getSteps();
  const isLastStep: boolean = activeStep === steps.length - 1;

  const handleNext = () => setActiveStep(activeStep + 1);
  const handleBack = () => setActiveStep(activeStep - 1);

  const methods = useForm({
    mode: "all",
    resolver: yupResolver(schema),
    defaultValues: {
      ...wizard,
    },
    values: values,
  });

  const onSubmit = () => {
    const formValues: Wizard = methods.getValues() as unknown as Wizard;
    dispatch(submitWizardForm(formValues)).unwrap();
  };

  const handleDisabled = (): boolean => {
    let section: any;
    switch (activeStep) {
      case 0:
        section = "contactInfo";
        break;
      case 1:
        section = "eContacts";
        break;
      case 2:
        section = "pack";
        break;
      case 3:
        section = "vetInfo";
        break;
    }
    const fieldState = methods.getFieldState(section, methods.formState);
    return activeStep !== 3 ? fieldState.invalid : !methods.formState.isValid;
  };

  useEffect(() => {
    const subscription = methods.watch((value, { name, type }) => {
      dispatch(updateWizardForm(value));
    });
    return () => subscription.unsubscribe();
  }, [methods.watch]);

  return (
    <FormProvider {...methods}>
      <div role="form" onSubmit={methods.handleSubmit(onSubmit)}>
        <div>{getStepContent(activeStep)}</div>
        {activeStep === 0 ? (
          <div></div>
        ) : (
          <button onClick={handleBack}>back</button>
        )}

        <button
          disabled={handleDisabled()}
          onClick={!isLastStep ? handleNext : onSubmit}
        >
          {isLastStep ? "submit" : "next"}
        </button>
      </div>
    </FormProvider>
  );
}

export default Welcome;

This issue I'm having is determining whether each section (contactInfo, petInfo, vetInfo) isValid in order to properly handle whether to disable the next button. At the moment handleDisabled checks to see if the section is fieldState.invalid but this doesn't always return false if the section of the form is blank. I tried checking the section fieldState.isTouched and fieldState.isDirty but those don't check for each field in the section just the section as a whole. Meaning if a user touches/dirties a single field in the section isTouched/isDirty return true and getFieldState.invalid returns false which leads to users not knowing which fields they need to complete.

The methods on formState are ideal but they only work on the form as a whole. I want to check if a specific section isValid. Otherwise I would have to check each individual field using getFieldState in each section.

I have looked over the documentation but the only solution I see is to break up each section into a separate form and then use formState.isValid on each separate form section. Is there a way to keep the code as is and check isValid by section?

Answers

To handle form validation for each section individually while keeping your current structure, you can modify your approach slightly. Instead of checking if the entire section is invalid, you can check if any of the fields in the section are invalid.

Here's how you can do it:

const handleDisabled = (): boolean => {
  let section: any;
  switch (activeStep) {
    case 0:
      section = "contactInfo";
      break;
    case 1:
      section = "eContacts";
      break;
    case 2:
      section = "pack";
      break;
    case 3:
      section = "vetInfo";
      break;
  }

  // Get field states for the current section
  const sectionFields = methods.formState.dirtyFields;
  const sectionErrors = methods.formState.errors;

  // Check if any field in the section is invalid
  const isSectionInvalid = Object.keys(sectionFields).some(
    (fieldName) => fieldName.startsWith(section) && sectionErrors[fieldName]
  );

  // If it's not the last step, disable the next button if any field in the section is invalid
  // If it's the last step, disable the next button if the entire form is invalid
  return !isLastStep ? isSectionInvalid : !methods.formState.isValid;
};

In this approach:

  • We get the dirty fields and errors from the form state.
  • We check if any field in the current section has an error.
  • If it's not the last step, we disable the next button if any field in the section is invalid.
  • If it's the last step, we disable the next button if the entire form is invalid.

This way, users will know which fields they need to complete within each section, and the next button will be disabled accordingly.