Skip to content

Latest commit

 

History

History
817 lines (714 loc) · 21.6 KB

tutorial.md

File metadata and controls

817 lines (714 loc) · 21.6 KB

show final project: Creating dynamic forms where validation changes based on user actions is essential for handling complex forms. For example, if a user does not check the "Work Experience" box, the form's validation will differ compared to when it is checked. If the user selects the checkbox, additional validation is required, such as making the "Company Name" field mandatory for the form to be submitted. Similarly, for a "Known Languages" checkbox, if checked, the user should be able to add or remove languages, with each field having its own validation rules. This dynamic behavior can also apply to this radio group and any other dynamic form behavior that you desire. Implementing such functionality can be a bit tricky, but it can be done in a clear and concise manner with minimal code. So let's implement it together.

Tutorial Script

npm create vite@latest . npm i remove unnecessary also change app to named export and reimport it

install packages

npm i @emotion/react @emotion/styled @mui/icons-material @mui/material react-hook-form zod @hookform/resolvers

show package.json

<ThemeProvider
  theme={createTheme({
    palette: {
      mode: "dark",
    },
  })}
>
  <CssBaseline />

  <App />
</ThemeProvider>

for better organization and not making the main part of the tutorial complex, we create a container to put our app inside it

import { Box, Paper, Stack, Typography } from "@mui/material";
import { ReactNode } from "react";

type Props = {
  children: ReactNode;
};
function Container({ children }: Props) {
  return (
    <Box
      sx={{
        height: "100dvh",
        display: "flex",
        justifyContent: "center",
        alignItems: "center",
      }}
    >
      <Paper sx={{ width: 400, padding: 2 }}>
        <Stack sx={{ gap: 2 }}>
          <Typography variant="h6">Employment Form</Typography>
          {children}
        </Stack>
      </Paper>
    </Box>
  );
}

export { Container };
import { TextField } from "@mui/material";
import { Container } from "./Container";

function App() {
  return (
    <Container>
      <TextField label="Full Name" />
    </Container>
  );
}

export { App };

but before we continue liking and subscribing can really help my career on youtube. I really appreciate it

import { z } from "zod";

const formSchema = z.object({
  fullName: z.string().min(1),
});

type FormSchema = z.infer<typeof formSchema>;

const formDefaultValues: FormSchema = {
  fullName: "",
};

export { formDefaultValues, formSchema, type FormSchema };
import { TextField } from "@mui/material";
import { Container } from "./Container";
import { useForm } from "react-hook-form";
import { formDefaultValues, formSchema, FormSchema } from "./formSchema";
import { zodResolver } from "@hookform/resolvers/zod";

function App() {
  const {
    // [!code ++]
    register, // [!code ++]
    formState: { errors }, // [!code ++]
  } = useForm<FormSchema>({
    // [!code ++]
    mode: "all", // [!code ++]
    resolver: zodResolver(formSchema), // [!code ++]
    defaultValues: formDefaultValues, // [!code ++]
  }); // [!code ++]

  return (
    <Container>
      <TextField label="Full Name" /> {/* [!code --] */}
      <TextField // [!code ++]
        {...register("fullName")} // [!code ++]
        label="Full Name" // [!code ++]
        helperText={errors.fullName?.message} // [!code ++]
        error={!!errors.fullName} // [!code ++]
      /> {/* [!code ++] */}
    </Container>
  );
}

export { App };

show errors are working

now show full app and hasWorkExperience and its functionality we need to improve our schema to add this dynamic validation behavior

import { z } from "zod";

const workExperienceSchema = z.discriminatedUnion("hasWorkExperience", [
  z.object({
    hasWorkExperience: z.literal(true),
    companyName: z.string().min(1),
  }),
  z.object({ hasWorkExperience: z.literal(false) }),
]);

const formSchema = z
  .object({
    fullName: z.string().min(1),
  })
  .and(workExperienceSchema); // [!code ++]

type FormSchema = z.infer<typeof formSchema>;

const formDefaultValues: FormSchema = {
  fullName: "",
  hasWorkExperience: false, // [!code ++]
};

export { formDefaultValues, formSchema, type FormSchema };

now show typescript capabilities in defaultValues object and tag types

import { Checkbox, FormControlLabel, TextField } from "@mui/material";
import { Container } from "./Container";
import { useForm } from "react-hook-form";
import { formDefaultValues, formSchema, FormSchema } from "./formSchema";
import { zodResolver } from "@hookform/resolvers/zod";

function App() {
  const {
    register,
    formState: { errors },
    control, // [!code ++]
    handleSubmit, // [!code ++]
  } = useForm<FormSchema>({
    mode: "all",
    resolver: zodResolver(formSchema),
    defaultValues: formDefaultValues,
  });

  const hasWorkExperience = useWatch({ control, name: "hasWorkExperience" }); // [!code ++]

  const onSubmit: SubmitHandler<FormSchema> = (data) => {
    // [!code ++]
    alert(JSON.stringify(data, null, 2)); // [!code ++]
  }; // [!code ++]

  return (
    <Container>
      <TextField
        {...register("fullName")}
        label="Full Name"
        helperText={errors.fullName?.message}
        error={!!errors.fullName}
      />
      <FormControlLabel // [!code ++]
        {...register("hasWorkExperience")} // [!code ++]
        label="Work Experience?" // [!code ++]
        control={<Checkbox />} // [!code ++]
      /> {/* [!code ++] */}
      {hasWorkExperience && ( // [!code ++]
        <TextField // [!code ++]
          {...register("companyName")} // [!code ++]
          label="Company Name" // [!code ++]
          helperText={errors.companyName?.message} // [!code ++]
          error={!!errors.companyName} // [!code ++]
        /> // [!code ++]
      )}{" "}
      {/* [!code ++] */}
      <Button variant="contained" onClick={handleSubmit(onSubmit)}>
        {" "}
        {/* [!code ++] */}
        Submit {/* [!code ++] */}
      </Button> {/* [!code ++] */}
    </Container>
  );
}

export { App };

now show the submit button that only works when form is validated

now the problem here is that the typescript is not smart enough to guess the errors from formState so we need to extend it a little for errors to be gone

import { Checkbox, FormControlLabel, TextField } from "@mui/material";
import { Container } from "./Container";
import { useForm } from "react-hook-form";
import { formDefaultValues, formSchema, FormSchema } from "./formSchema";
import { zodResolver } from "@hookform/resolvers/zod";

function App() {
  const {
    register,
    formState: { errors },
    control,
    handleSubmit,
  } = useForm<FormSchema>({
    mode: "all",
    resolver: zodResolver(formSchema),
    defaultValues: formDefaultValues,
  });

  const fullErrors: FieldErrors<
    // [!code ++]
    Extract<FormSchema, { hasWorkExperience: true }> // [!code ++]
  > = errors; // [!code ++]

  const hasWorkExperience = useWatch({ control, name: "hasWorkExperience" });

  const onSubmit: SubmitHandler<FormSchema> = (data) => {
    alert(JSON.stringify(data, null, 2));
  };

  return (
    <Container>
      <TextField
        {...register("fullName")}
        label="Full Name"
        helperText={errors.fullName?.message} // [!code --]
        error={!!errors.fullName} // [!code --]
        helperText={fullErrors.fullName?.message} // [!code ++]
        error={!!fullErrors.fullName} // [!code ++]
      />
      <FormControlLabel
        {...register("hasWorkExperience")}
        label="Work Experience?"
        control={<Checkbox />}
      />
      {hasWorkExperience && (
        <TextField
          {...register("companyName")}
          label="Company Name"
          helperText={errors.companyName?.message} // [!code --]
          error={!!errors.companyName} // [!code --]
          helperText={fullErrors.companyName?.message} // [!code ++]
          error={!!fullErrors.companyName} // [!code ++]
        />
      )}{" "}
      <Button variant="contained" onClick={handleSubmit(onSubmit)}>
        {" "}
        Submit
      </Button>
    </Container>
  );
}

export { App };

CTA

now show knowsOtherLanguages checkbox from final app

import { z } from "zod";

const workExperienceSchema = z.discriminatedUnion("hasWorkExperience", [
  z.object({
    hasWorkExperience: z.literal(true),
    companyName: z.string().min(1),
  }),
  z.object({ hasWorkExperience: z.literal(false) }),
]);

const languageKnowledgeSchema = z.discriminatedUnion("knowsOtherLanguages", [
  // [!code ++]
  z.object({
    // [!code ++]
    knowsOtherLanguages: z.literal(true), // [!code ++]
    languages: z // [!code ++]
      .array(
        // [!code ++]
        z.object({
          // [!code ++]
          name: z.string().min(1), // [!code ++]
        }) // [!code ++]
      ) // [!code ++]
      .min(1), // [!code ++]
  }), // [!code ++]
  z.object({ knowsOtherLanguages: z.literal(false) }), // [!code ++]
]); // [!code ++]

const formSchema = z
  .object({
    fullName: z.string().min(1),
  })
  .and(workExperienceSchema)
  .and(languageKnowledgeSchema); // [!code ++]

type FormSchema = z.infer<typeof formSchema>;

const formDefaultValues: FormSchema = {
  fullName: "",
  hasWorkExperience: false,
  knowsOtherLanguages: false, // [!code ++]
};

export { formDefaultValues, formSchema, type FormSchema };
import { Button, Checkbox, FormControlLabel, TextField } from "@mui/material";
import { Container } from "./Container";
import { FieldErrors, SubmitHandler, useForm, useWatch } from "react-hook-form";
import { formDefaultValues, formSchema, FormSchema } from "./formSchema";
import { zodResolver } from "@hookform/resolvers/zod";

function App() {
  const {
    register,
    formState: { errors },
    control,
    handleSubmit,
  } = useForm<FormSchema>({
    mode: "all",
    resolver: zodResolver(formSchema),
    defaultValues: formDefaultValues,
  });

  const fullErrors: FieldErrors<
    Extract<FormSchema, { hasWorkExperience: true }>
  > = errors;

  const hasWorkExperience = useWatch({ control, name: "hasWorkExperience" });

  const onSubmit: SubmitHandler<FormSchema> = (data) => {
    alert(JSON.stringify(data, null, 2));
  };

  return (
    <Container>
      <TextField
        {...register("fullName")}
        label="Full Name"
        helperText={fullErrors.fullName?.message}
        error={!!fullErrors.fullName}
      />
      <FormControlLabel
        {...register("hasWorkExperience")}
        label="Work Experience?"
        control={<Checkbox />}
      />
      {hasWorkExperience && (
        <TextField
          {...register("companyName")}
          label="Company Name"
          helperText={fullErrors.companyName?.message}
          error={!!fullErrors.companyName}
        />
      )}
      <FormControlLabel // [!code ++]
        {...register("knowsOtherLanguages")} // [!code ++]
        label="Know Other Languages?" // [!code ++]
        control={<Checkbox />} // [!code ++]
      /> {/* [!code ++] */}
      <Button variant="contained" onClick={handleSubmit(onSubmit)}>
        Submit
      </Button>
    </Container>
  );
}

export { App };

now show from final app that when we click on the knowsOtherLanguages checkbox, we need to see array fields and add or remove languages

to do that:

import { Button, Checkbox, FormControlLabel, TextField } from "@mui/material";
import { Container } from "./Container";
import { FieldErrors, SubmitHandler, useForm, useWatch } from "react-hook-form";
import { formDefaultValues, formSchema, FormSchema } from "./formSchema";
import { zodResolver } from "@hookform/resolvers/zod";

function App() {
  const {
    register,
    formState: { errors },
    control,
    handleSubmit,
  } = useForm<FormSchema>({
    mode: "all",
    resolver: zodResolver(formSchema),
    defaultValues: formDefaultValues,
  });

  const fullErrors: FieldErrors<
    Extract<FormSchema, { hasWorkExperience: true }>
  > = errors;

  const hasWorkExperience = useWatch({ control, name: "hasWorkExperience" });
  const knowsOtherLanguages = useWatch({
    // [!code ++]
    control, // [!code ++]
    name: "knowsOtherLanguages", // [!code ++]
  }); // [!code ++]

  const onSubmit: SubmitHandler<FormSchema> = (data) => {
    alert(JSON.stringify(data, null, 2));
  };

  return (
    <Container>
      <TextField
        {...register("fullName")}
        label="Full Name"
        helperText={fullErrors.fullName?.message}
        error={!!fullErrors.fullName}
      />
      <FormControlLabel
        {...register("hasWorkExperience")}
        label="Work Experience?"
        control={<Checkbox />}
      />
      {hasWorkExperience && (
        <TextField
          {...register("companyName")}
          label="Company Name"
          helperText={fullErrors.companyName?.message}
          error={!!fullErrors.companyName}
        />
      )}
      <FormControlLabel
        {...register("knowsOtherLanguages")}
        label="Know Other Languages?"
        control={<Checkbox />}
      />
      {knowsOtherLanguages && <>array fields here</>} {/* [!code ++] */}
      <Button variant="contained" onClick={handleSubmit(onSubmit)}>
        Submit
      </Button>
    </Container>
  );
}

export { App };

so we need to use the amazing array fields from rhf:

import { Button, Checkbox, FormControlLabel, TextField } from "@mui/material";
import { Container } from "./Container";
import {
  FieldErrors,
  SubmitHandler,
  useFieldArray,
  useForm,
  useWatch,
} from "react-hook-form";
import { formDefaultValues, formSchema, FormSchema } from "./formSchema";
import { zodResolver } from "@hookform/resolvers/zod";

function App() {
  const {
    register,
    formState: { errors },
    control,
    handleSubmit,
  } = useForm<FormSchema>({
    mode: "all",
    resolver: zodResolver(formSchema),
    defaultValues: formDefaultValues,
  });

  const {
    // [!code ++]
    fields: languagesFields, // [!code ++]
    replace: replaceLanguages, // [!code ++]
    append: appendLanguages, // [!code ++]
    remove: removeLanguages, // [!code ++]
  } = useFieldArray({
    // [!code ++]
    control, // [!code ++]
    name: "languages", // [!code ++]
  }); // [!code ++]

  const fullErrors: FieldErrors<
    Extract<FormSchema, { hasWorkExperience: true }>
  > = errors;

  const hasWorkExperience = useWatch({ control, name: "hasWorkExperience" });
  const knowsOtherLanguages = useWatch({
    control,
    name: "knowsOtherLanguages",
  });

  const onSubmit: SubmitHandler<FormSchema> = (data) => {
    alert(JSON.stringify(data, null, 2));
  };

  return (
    <Container>
      <TextField
        {...register("fullName")}
        label="Full Name"
        helperText={fullErrors.fullName?.message}
        error={!!fullErrors.fullName}
      />
      <FormControlLabel
        {...register("hasWorkExperience")}
        label="Work Experience?"
        control={<Checkbox />}
      />

      {hasWorkExperience && (
        <TextField
          {...register("companyName")}
          label="Company Name"
          helperText={fullErrors.companyName?.message}
          error={!!fullErrors.companyName}
        />
      )}
      <FormControlLabel
        {...register("knowsOtherLanguages")}
        label="Know Other Languages?"
        control={<Checkbox />}
      />
      {knowsOtherLanguages && <>array fields here</>}
      <Button variant="contained" onClick={handleSubmit(onSubmit)}>
        Submit
      </Button>
    </Container>
  );
}

export { App };
import {
  Button,
  Checkbox,
  FormControlLabel,
  IconButton,
  TextField,
} from "@mui/material";
import { Container } from "./Container";

import { zodResolver } from "@hookform/resolvers/zod";
import DeleteForeverRoundedIcon from "@mui/icons-material/DeleteForeverRounded";
import {
  FieldErrors,
  SubmitHandler,
  useFieldArray,
  useForm,
  useWatch,
} from "react-hook-form";
import { formDefaultValues, formSchema, FormSchema } from "./formSchema";

function App() {
  const {
    register,
    formState: { errors },
    control,
    handleSubmit,
  } = useForm<FormSchema>({
    mode: "all",
    resolver: zodResolver(formSchema),
    defaultValues: formDefaultValues,
  });

  const {
    fields: languagesFields,
    replace: replaceLanguages,
    append: appendLanguages,
    remove: removeLanguages,
  } = useFieldArray({
    control,
    name: "languages",
  });

  const fullErrors: FieldErrors<
    Extract<FormSchema, { hasWorkExperience: true }>
  > = errors;

  const hasWorkExperience = useWatch({ control, name: "hasWorkExperience" });
  const knowsOtherLanguages = useWatch({
    control,
    name: "knowsOtherLanguages",
  });

  const onSubmit: SubmitHandler<FormSchema> = (data) => {
    alert(JSON.stringify(data, null, 2));
  };

  return (
    <Container>
      <TextField
        {...register("fullName")}
        label="Full Name"
        helperText={fullErrors.fullName?.message}
        error={!!fullErrors.fullName}
      />
      <FormControlLabel
        {...register("hasWorkExperience")}
        label="Work Experience?"
        control={<Checkbox />}
      />
      {hasWorkExperience && (
        <TextField
          {...register("companyName")}
          label="Company Name"
          helperText={fullErrors.companyName?.message}
          error={!!fullErrors.companyName}
        />
      )}
      <FormControlLabel
        {...register("knowsOtherLanguages")}
        label="Know Other Languages?"
        control={<Checkbox />}
      />
      {knowsOtherLanguages && (
        <>
          {languagesFields.map(
            (
              field,
              index // [!code ++]
            ) => (
              <div key={field.id}>
                <TextField // [!code ++]
                  sx={{ width: "100%" }} // [!code ++]
                  {...register(`languages.${index}.name`)} // [!code ++]
                  label="Language Name" // [!code ++]
                  helperText={fullErrors.languages?.[index]?.name?.message} // [!code ++]
                  error={!!fullErrors.languages?.[index]?.name?.message} // [!code ++]
                />{" "}
                // [!code ++]
                <IconButton // [!code ++]
                  disabled={languagesFields.length === 1} // [!code ++]
                  onClick={() => removeLanguages(index)} // [!code ++]
                  color="error" // [!code ++]
                >
                  {" "}
                  // [!code ++]
                  <DeleteForeverRoundedIcon /> // [!code ++]
                </IconButton> // [!code ++]
              </div> // [!code ++]
            )
          )}{" "}
          // [!code ++]
          <IconButton // [!code ++]
            sx={{ width: "fit-content" }} // [!code ++]
            onClick={() => appendLanguages({ name: "" })} // [!code ++]
            color="success" // [!code ++]
          >
            {" "}
            // [!code ++]
            <AddCircleRoundedIcon /> // [!code ++]
          </IconButton> // [!code ++]
        </> // [!code ++]
      )}{" "}
      // [!code ++]
      <Button variant="contained" onClick={handleSubmit(onSubmit)}>
        Submit
      </Button>
    </Container>
  );
}

export { App };

now we have errors on fullErrors on languages so we need to extended

const fullErrors: FieldErrors<
  Extract<FormSchema, { hasWorkExperience: true }>
> &
  FieldErrors<Extract<FormSchema, { knowsOtherLanguages: true }>> = // [!code ++]
  errors;

now if check the box we see nothing is showing because we did not add anything in here (hover over fields.map) to show. to do that

// above return
useEffect(() => {
  if (knowsOtherLanguages) {
    replaceLanguages([{ name: "" }]);
  }
}, [knowsOtherLanguages, replaceLanguages]);

show education level on final project

const educationSchema = z.discriminatedUnion("educationLevel", [
  z.object({
    educationLevel: z.literal("noFormalEducation"),
  }),
  z.object({
    educationLevel: z.literal("highSchoolDiploma"),
    schoolName: z.string().min(1),
  }),
  z.object({
    educationLevel: z.literal("bachelorsDegree"),
    universityName: z.string().min(1),
  }),
]);

// on the formSchema
.and(educationSchema)
// above submit button
<FormControl>
  <FormLabel>Education Level</FormLabel>
  <Controller
    control={control}
    name="educationLevel"
    render={({ field }) => (
      <RadioGroup {...field}>
        <FormControlLabel
          value="noFormalEducation"
          control={<Radio />}
          label="No Formal Education"
        />
        <FormControlLabel
          value="highSchoolDiploma"
          control={<Radio />}
          label="High School Diploma"
        />
        <FormControlLabel
          value="bachelorsDegree"
          control={<Radio />}
          label="Bachelors Degree"
        />
      </RadioGroup>
    )}
  />
</FormControl>
const educationLevel = useWatch({ control, name: "educationLevel" });
// above submit button
{
  educationLevel === "highSchoolDiploma" && (
    <TextField
      {...register("schoolName")}
      label="High School Name"
      helperText={fullErrors.schoolName?.message}
      error={!!fullErrors.schoolName?.message}
    />
  );
}
{
  educationLevel === "bachelorsDegree" && (
    <TextField
      {...register("universityName")}
      label="University Name"
      helperText={fullErrors.universityName?.message}
      error={!!fullErrors.universityName?.message}
    />
  );
}
FieldErrors<Extract<FormSchema, { educationLevel: "noFormalEducation" }>> &
  FieldErrors<Extract<FormSchema, { educationLevel: "highSchoolDiploma" }>> &
  FieldErrors<Extract<FormSchema, { educationLevel: "bachelorsDegree" }>>;