react-hook-form + yup + @hookform/resolvers/yup

Jinmin Kim·2025년 5월 21일

react-hook-form

사용하는것에는 처음에는 러닝커브가 있으나, 점점 익숙해진다.
그리고 리랜더를 최대한 잘 관리할수잇는것이 장점.
여러 값들을 컴포넌트로 별로 나누면, 불필요한 렌더가 안되게 되어서 좋다.

근데 얘도 하나의 Context로 사용하는것이다보니,
만약 store 급의 범위를 가지게 되면, 데이터를 어떻게 해야할지,
또는 이중관리를 해야할지 생각을 하게된다.

최상단에 해줘야할 세팅

	const methods = useForm<FormValues>({
    resolver: yupResolver(
      registerAccountSchema,
    ) as unknown as Resolver<FormValues>,
    context: { hasTradeGroupC }, 
      //컨택스트로 내부에서 조건문으로 사용
    shouldFocusError: false,
    shouldUnregister: false,
    defaultValues: {
      //..데이터어어
    }

    <FormProvider {...methods}>
      <form
        onSubmit={methods.handleSubmit(onSubmit)}
      >
        //...
      </form>
    </FormProvider>

useFieldArray

  const {
    control,
    formState: { errors },
    register, // register도 꺼낼 수 있음
    watch, // watch도 가능
    setFocus,
  } = useFormContext<FormValues>();

const {
    fields: infoListField,
    append: infoListAppend,
    remove: infoListRemove,
    update: infoListUpdate,
  } = useFieldArray({
    control,
    name: "rightInfo.infoList",
  });
  
  const companyRegNo = useWatch({
    control,
    name: "leftInfo.info.regNo",
    defaultValue: "", // 옵셔널
  });

return (
  <>
    //...
{infoListField.map((item, index) => (
          <React.Fragment key={`infoListField{item?.id}`}>
            <Controller
              key={`infoListFieldController${index}`}
              name={`rightInfo.infoList.${index}`}
              control={control}
              render={({ field }) => {
    //...

Controller

  const {
    control,
    formState: { errors },
    register, // register도 꺼낼 수 있음
    watch, // watch도 가능
    setValue,
  } = useFormContext<FormValues>();


<Controller
  name="leftInfo.info.type"
  control={control}
  render={({ field }) => (
    <KeyValueSelectBox
      options={options}
      value={field.value}
      onChange={field.onChange}
      size="95px"
      />
  )}
  />

useController

  const {
    control,
    formState: { errors },
    register, // register도 꺼낼 수 있음
    watch, // watch도 가능
    setValue,
  } = useFormContext<FormValues>();

  const { field: NameField } = useController<
    FormValues,
    "leftInfo.info.Name"
  >({
    name: "leftInfo.info.Name",
    control,
    rules: { required: true },
  });

register.schema.ts

import { object, string, array } from "yup";

export const options = {
  "0": "Option A",
  "1": "Option B",
  "2": "Option C",
  "3": "Option D",
  "4": "Option E",
} as const;

const optionKeys = Object.keys(options) as Array<keyof typeof options>;

export const formSchema = object({
  basicInfo: object({
    selectField: object({
      choice: string()
        .oneOf(optionKeys, "올바른 옵션을 선택해주세요")
        .required("옵션 선택은 필수입니다"),
      label: string().trim().required("라벨은 필수입니다"),
      id: string()
        .trim()
        .max(12, "최대 10자만 입력 가능합니다")
        .matches(/^[0-9-]+$/, "숫자와 하이픈(-)만 허용됩니다")
        .test(
          "length-10-digits",
          "하이픈 제외 숫자 10자리여야 합니다",
          (v = "") => v.replace(/-/g, "").length === 10
        )
        .required("ID는 필수입니다"),
      ownerName: string().required("이름은 필수입니다"),
      subId: string()
        .trim()
        .max(4, "최대 4자만 입력 가능합니다")
        .matches(/^\d*$/, "숫자만 입력해주세요"),
      corpId: string()
        .trim()
        .max(12, "최대 12자만 입력 가능합니다")
        .matches(/^\d*$/, "숫자만 입력해주세요"),
      typeCode: string().max(10, "최대 10자만 입력 가능합니다"),
      format: string().trim(),
      category: string().trim(),
      phones: array()
        .of(
          string()
            .trim()
            .max(16, "최대 16자만 입력 가능합니다")
            .matches(/^\d*$/, "숫자만 입력해주세요")
        )
        .ensure()
        .min(1, "최소 하나 이상의 전화번호가 필요합니다"),
      faxNumber: string()
        .trim()
        .max(16, "최대 16자만 입력 가능합니다")
        .matches(/^\d*$/, "숫자만 입력해주세요"),
      address: string().trim().max(256, "최대 256자만 입력 가능합니다"),
    }).required(),
    localeInfo: object({
      labelEn: string().matches(/^[A-Za-z]*$/, "영문 알파벳만 입력해주세요"),
      addressEn: string().matches(
        /^[A-Za-z0-9]*$/,
        "영문/숫자만 입력해주세요"
      ),
      ownerNameEn: string().matches(/^[A-Za-z]*$/, "영문 알파벳만 입력해주세요"),
    }).required(),
    financialInfo: object({
      names: array().of(string().trim()).ensure(),
      number: string()
        .trim()
        .min(10, "최소 10자리입니다")
        .max(14, "최대 14자리입니다")
        .matches(/^\d*$/, "숫자만 입력해주세요"),
      bankName: string(),
      bankCode: string(),
    }).required(),
    extraInfo: object({
      url: string(),
      notes: string(),
      tag: string(),
    }).required(),
  }).required(),
  details: object({
    items: array()
      .of(
        object({
          image: string().ensure(),
          organization: string().ensure(),
          contact: string()
            .trim()
            .max(16, "최대 16자만 입력 가능합니다")
            .matches(/^[0-9-]*$/, "숫자와 하이픈만 허용됩니다")
            .ensure(),
          phone: string()
            .trim()
            .max(16, "최대 16자만 입력 가능합니다")
            .matches(/^[0-9-]*$/, "숫자와 하이픈만 허용됩니다")
            .ensure(),
          fax: string()
            .trim()
            .max(16, "최대 16자만 입력 가능합니다")
            .matches(/^[0-9-]*$/, "숫자와 하이픈만 허용됩니다"),
          email: string()
            .trim()
            .test(
              "email-if-filled",
              "유효한 이메일 형식(예: a@b.com)이어야 합니다",
              (v = "") => !v || /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(v)
            )
            .ensure(),
          task: string().ensure(),
          regId: string().ensure(),
          remark: string().ensure(),
          name: string()
            .ensure()
            .test(
              "name-required-if-other",
              "하나 이상의 정보 입력 시 이름이 필요합니다",
              function (v = "") {
                const parent = this.parent as Record<string, any>;
                const others = [
                  parent.organization,
                  parent.contact,
                  parent.phone,
                  parent.fax,
                  parent.email,
                  parent.task,
                  parent.regId,
                  parent.remark,
                ];
                const anyFilled = others.some((x) => x?.trim() !== "");
                return !anyFilled || v.trim() !== "";
              }
            ),
        }).required()
      )
      .min(1, "최소 하나 이상의 항목이 필요합니다")
      .required(),
  }).required(),
});

값이 들어오는지 체크해보기

아래와같이, schema에 넣어주면 들어오는 값을 확인해볼수있다.

.test("debug-hasC", "debug", function (v) {
  // eslint-disable-next-line no-console
  console.log(
    "🧪 hasTradeGroupC in context:",
    this?.options?.context?.hasTradeGroupC,
  );
    return true; // 항상 통과
    })

부분적으로 필수사항 및 조건 추가하기

schema에 when절 추가하여, 부분적으로 요구사항 및 필수값을 수정할수있다.

companyRegNo: string() 
  .trim()
.max(
  10,
  intl.formatMessage({ id: "tr.register.validation.hh" }),
)
.when("$hasTradeGroupC", {
  is: Boolean, // ← truthy면 필수
  then: (s) =>
  s.required(
    intl.formatMessage({
      id: "tr.register.validation.gg",
    }),
  ),
  otherwise: (s) => s.notRequired(),
}),

update와 setValue의 차이점

  const {
    control,
    formState: { errors },
    register, 
    watch,
    getValues,
    setValue,
  } = useFormContext<FormValues>();

  const {
    fields: addressField,
    append: addressAppend,
    remove: addressRemove,
    update: addressUpdate,
  } = useFieldArray({
    control,
    name: "leftInfo.info.address",
  });

1️⃣ update

  • 배열 요소 전체 객체를 대체(replace)할 때 사용
  • 호출 시 해당 필드 배열(fields) 전체가 갱신되어, 배열을 순회(render)하는 모든 Controller가 다시 렌더링됩니다.

2️⃣ setValue

  • 배열 요소 중 특정 네임스페이스(예: .addressValue)만 변경할 때 사용
  • React Hook Form은 해당 필드 경로만을 구독한 컴포넌트(Controller)만 리렌더링합니다.
  • ❌ “배열 전체를 리렌더링” 하지 않고, 변경된 필드에만 반영됩니다.

➡️ 따라서,

  • update는 전체, setValue는 부분” ✅
  • setValue는 배열 전체를 리렌더” ❌ (부분 리렌더가 이루어집니다)

useController error🔥

useController.ts:144 Uncaught TypeError: elm.focus is not a function
at Object.focus (useController.ts:144:1)

useController가 제공하는 ref는 실제 DOM input 요소에만 연결되어야 하는데, 커스텀 Component나 다른 라이브러리 Component에서 React.forwardRef를 지원하지 않는다면, <Controller />나 register API 사용을 고려해보아야 한다.

profile
Let's do it developer

0개의 댓글