본문 바로가기
개발개발/WorkRoot_워크루트

리액트 훅 폼 (React Hook Form) 트러블 슈팅 2 - 여러 유형의 폼 데이터 제출하기

by yelimu 2025. 1. 9.

 

 

리액트 훅 폼 (React Hook Form) 트러블 슈팅 1 - 여러 탭에 걸친 폼 데이터 제출하기(watch, setValue)

 

리액트 훅 폼 (React Hook Form) 트러블 슈팅 1 - 여러 탭에 걸친 폼 데이터 제출하기(watch, setValue 사용

위 슬라이드는 사용자가 알바 공고를 작성하기 위한 페이지이다.1. 모집 내용 2.모집 조건 3. 근무 조건 총 세가지 탭을 작성해서 전체 폼 데이터를 제출해야 한다.  🤔간단한 게시글이나 댓글

memoryelim.tistory.com

에 이어서 유형별 input 값 리액트 훅폼에 등록하는 방법 정리해보기!

 


 

유형별 input 값 리액트 훅폼에 등록하기 

~~ input 타입 별로 정리해보자 ~~

 

1. Text 

제일 간단히 구현했다.

register로 해당 필드를 리액트 훅폼에서 관리하도록 등록해주면

내부적으로 입력 필드의 이벤트(예: onChange, onBlur, onFocus 등)를 처리하며 상태를 자동으로 업데이트해주기 때문에

명시적으로 setValue를 사용할 필요가 없다. 

<Label>제목</Label>
        <BaseInput
          {...register("title", { required: "제목을 입력해주세요" })}
          type="text"
          placeholder="제목을 입력해주세요."
          errormessage={errors.title?.message}
        />

 

그리고 register에는 ref도 포함이 되어있다. 

ref를 통해 해당 DOM 요소를 React Hook Form의 상태와 연결하는데 

ref는 prop으로 직접 전달할 수 없으므로 

컴포넌트 내부에서 forwardRef로 감싸주어야 한다. (리액트 19부터는 불필요하다고 들었다)

const BaseInput = forwardRef<HTMLInputElement, BaseInputProps>(
  ( {...props}
 },
    ref
  ) => 
   // ... 코드 작성
}

2. Date 

 // displayRange를 상위에서 관리
  const displayDate =
    recruitEndDate && !formatToLocaleDate(recruitEndDate).includes("NaN")
      ? `${formatToLocaleDate(recruitStartDate)} ~ ${formatToLocaleDate(recruitEndDate)}`
      : "";
  
 // 날짜 변경 핸들러
 const handleRecruitmentDateChange = (dates: [Date | null, Date | null]) => {
    const [start, end] = dates;
    setValue("recruitmentStartDate", start ? start.toISOString() : null);
    setValue("recruitmentEndDate", end ? end.toISOString() : null, { shouldDirty: true });
  };

스웨거 상, 폼 제출 시 요구되는 날짜 형식은 UTC시간대이다. (toISOString)

UTC 시간대 : Date.toISOString() 
` "workEndDate": "2024-12-06T12:38:50.412Z",`

 

보여주고 싶은 값 형식은 요러케.. => displayDate를 value로 전달한다.
Date.toLocaleString()
`"2024. 12. 6. 21:38:50"`

<DatePickerInput
            startDate={startDate}
            endDate={endDate}
            onChange={handleRecruitmentDateChange}
            required={true}
            errormessage={isDirty && !endDate}
            displayValue={displayDate}
          />

 

컴포넌트에서 관리할 값이 시작일과 종료일 두 개여서 register를 사용하지 않고 onChange 안에서 각각 setValue 해주었다. 

그런데 이게 과연 좋은 방법일지 궁금해져서 지피티에게 물어보니 

더보기

상태 관리가 복잡할 경우에는 사용해도 되지만 코드가 복잡해질 수 있고 성능최적화 이점을 얻을 수 없으니

리액트훅폼을 활용하는 방법으로 register와 Controller를 사용하는 방법을 각각 알려주었다

 

1. 첫번째 방법 :  register + Controller

<DatePickerInput
  startDate={startDate}
  endDate={endDate}
  {...register("startDate")}
  {...register("endDate")}
  onChange={handleRecruitmentDateChange}
/>

=> 이 경우에는 각각의 ref를 에 넘겨 주기 위해 시작일/종료일 input 이 각각 필요하다. 

내 경우와 같이 데이터피커를 사용할때는 Controller를 함께 사용해야 한다. 

  <Controller
        name="recruitmentDates"
        control={control}
        defaultValue={{ startDate: null, endDate: null }}
        render={({ field }) => (
          <DatePicker
            selectsRange
            startDate={startDate}
            endDate={endDate}
            onChange={handleDateChange}
            isClearable
            placeholderText="날짜 범위를 선택하세요"
            {...field} // React Hook Form과 연결
          />
        )}
      />

이게 더 복잡해지는거같은데,,? 

 

2. 두번째 방법 : Controller 

<Controller
  name="recruitmentDates"
  control={control}
  render={({ field }) => (
    <DatePickerInput
      startDate={field.value?.startDate}
      endDate={field.value?.endDate}
      onChange={(dates) => field.onChange(dates)}
      required={true}
      displayValue={displayDate}
    />
  )}
/>

이 경우가 훨씬 간단하다! 

컴포넌트 내부에도 forwardRef만 감싸주면 된다 👍

react-datepicker UI 커스텀 관련 포스팅은 여긔

 

데이터 피커 (react-datepicker) 달력 UI 커스텀하기

react-datepicker 라이브러리의 달력 UI 커스텀 과정 기록입니다. 선택한 날짜 값과 관련된 로직은 제외하고 UI 컴포넌트 관련된 내용만 먼저 정리해보려고 한다. 대략적인 구조는 이렇게 구성했다.

memoryelim.tistory.com


3. Image file

<ImageInput
            {...register("imageUrls")}
            onChange={(files: File[] | string[]) => {
              handleChangeImages(files);
            }}
            onDelete={(id) => handleDeleteImage(id)}
            initialImageList={initialImageList}
          />

날짜와 동일하게 onChange와 onDelete를 별도로 작성했다. 

  // 이미지 파일 change핸들러
  const handleChangeImages = async (files: File[] ) => {
    if (files.length > 0 ) {
      let uploadedUrls: string[] = [];
      try {
        uploadedUrls = await uploadImages(files as File[]);
      } catch (err) {
        console.log("이미지 파일 체인지 핸들러 - 이미지 업로드 실패");
        console.error(err);
      }

      // 선택한 이미지 업데이트
      const updatedImageList = uploadedUrls.map((url) => ({
        url,
        id: crypto.randomUUID(),
      }));

      // 기존 이미지 포함하기
      const originalImageList = imageUrlsData.map((url) => ({
        url,
        id: crypto.randomUUID(),
      }));

      const allImageList = [...originalImageList, ...updatedImageList];
      const submitImageList = [...imageUrlsData, ...uploadedUrls];

      // prop으로 전달
      setInitialImageList(allImageList);
      // 훅폼 데이터에 세팅
      setValue("imageUrls", submitImageList, { shouldDirty: true });
    }
  };
      // 이미지 삭제 핸들러 
  const handleDeleteImage = (url: string) => {
    const newImageList = initialImageList.filter((item) => item.url !== url);
    setInitialImageList(newImageList);
    const urls = newImageList.map((item) => item.url);
    setValue("imageUrls", urls, { shouldDirty: true });
  };
 

AWS S3에 업로드 해서 url를 반환 받도록 백엔드가 구성되어있어서 

파일을 선택할때마다 onChange 핸들러에서 api 요청을 보내도록 했다.

 

이 부분을 구현하면서 했던 고민을 간단히 적어보겠다 

바로 어제 이와 관련된 포스팅을 했는데 어째선지 실수로 삭제해서 ㅠ ㅜ 

 

🤔 파일 업로드 api 요청 시점을 언제로 할 것인지 ?

1. 파일 선택 시 (onChange 핸들러에서) 

2. 폼 제출 시 (onSubmit 핸들러에서) 

 

✅ 처음엔 후자의 방법으로 구현했다. 

사용자가 폼 제출 이전에 이미지를 추가했다가 삭제하고 다시 추가할 경우 불필요한 api 요청이 발생하는 것을 방지 하기 위해서였다. 

💣 그러나 이 경우 불필요하게 관리해야 할 상태가 추가됐다.

File을 받아서 api 요청 -> 프리뷰는 blob url로 보여줌 & url 받아서 폼 데이터에 저장

=> File, url 두 개의 상태 관리

 

💣 임시저장할때도 이미지 업로드 api요청을 하다보니 중복된 로직이 발생했다. 

(리액트쿼리를 사용해 중복 요청이 발생하진 않았을 것이다.)

 

💣 결정적으로 수정 페이지에서 에러가 발생했다. 

작성 시 추가한 기존의 이미지와 새로 선택한 이미지 파일이 있을때 

프리뷰를 보여주는 것 까지는 url로 보여주면 되지만 

기존의 이미지는 파일을 가지고 있지 않고 url만 가지고 있기 때문에 이미지 업로드 api에 파일을 넘겨줄 수 없기 때문이다.

(파일은 제출할 폼데이터 필드에 포함되지 않으며, 불러올때도 이미지 url 데이터만 포함되어있다.)

 

⛏😂 따라서 이러한 문제들을 해결하기 위해 파일 선택 시 파일 업로드를 하는 것으로 수정을 했다.


4. Dropdown에서 값을 선택하는 경우 

<TimePickerInput
            variant="white"
            value={workStartTime}
            {...register("workStartTime", { required: "근무 시작 시간을 선택해주세요" })}
            onChange={(e: ChangeEvent<HTMLInputElement>) => {
              setValue("workStartTime", e.target.value, { shouldDirty: true });
            }}
          />

 

컴포넌트 내부에는 forwardRef로 감싸주고 input에 ref를 전달한다. 

  // TimePickerInput.tsx
  // 시간 선택 핸들러
  const handleSelect = useCallback(
    (time: string | null) => {
      if (!time) {
        setIsOpen(false);
        return;
      }

      if (onChange) {
        const event = {
          target: { value: time, name: props.name },
        } as React.ChangeEvent<HTMLInputElement>;
        onChange(event);
      }

      setIsOpen(false);
    },
    [onChange, props.name, setIsOpen]
  );
 
...
return (
...
    <BaseInput
          ref={ref}
          type="text"
          placeholder="00:00"
          value={value || ""}
...
    <DropdownList
          list={timeOption}
          onSelect={handleSelect}
        />
)

time 값을 선택하면 change event로 단언하여 이벤트를 onChange에 넘겨준다. 

상위 컴포넌트에서 workStartTime값을 setValue하고 해당 값이 다시 value prop으로 컴포넌트에 내려가 시간이 표시된다.


5. 시급 

보여주고 싶은 값 : 세자리 단위 콤마 = string 

제출해야 하는 값 : 콤마 제거한 number 

타입이 달라서 나름대로 이를 타개하기 위해 굉장히 코드가 길어졌었다

세자리 단위마다 콤마를 찍기 위해서 number.toLocaleString() 메서드를 사용하면 된다는 것을 몰랐던 나는,, 함수로 구현을 했었다 ㅋㅋㅋ

이렇게 알게됐으니 오히려 좋아^^ 다신 안잊을듯

 

유일하게 Controller를 사용한 필드인데, 멘토님이랑 같이 리팩토링 했었다 ㅎㅎ

<Controller
            name="hourlyWage"
            control={control}
            rules={{
              required: "시급을 입력해주세요.",
              min: {
                value: MINIMUM_WAGE,
                message: `최저시급(${MINIMUM_WAGE.toLocaleString()}원) 이상을 입력해주세요.`,
              },
            }}
            render={({ field: { onChange, onBlur, value } }) => (
              <BaseInput
                value={value.toLocaleString()}
                onChange={(e) => onChange(Number(e.target.value.replaceAll(",", "")))}
                onBlur={onBlur}
                afterString="원"
              />
            )}
          />

 

value={value.toLocaleString()} 

이렇게 작성해줌으로써 보여지는 값인 value 는 세자리 단위 콤마가 찍힌 채로 나오고

 

onChange={(e) => onChange(Number(e.target.value.replaceAll(",", "")))}

리액트 훅폼에 등록되는 값은 숫자값으로 전달이 된다

 

register와 달리 Controller를 사용할때는 filed에서 사용할 메서드를 명시적으로 적어주어야 한다. 

또한 Controller가 자동으로 field.ref를 처리해주므로 ref를 전달할 필요가 없다고 한다 ~~


6. Boolean

          <CheckBtn label="공개 설정" checked={watch("isPublic")} {...register("isPublic")}  />
 

간단 스바라시 

checked 속성에 watch 값을 주어서 기존의 데이터가 있다면 그에 맞게 체크 표시되도록 함

마찬가지로 컴포넌트 내부에는 forwardRef 로 감싸주었고, ref를 전달했다.


 

필드 개수가 많아서 구현하는데 상당 기간이 소요됐는데 정리하고 보니 거의 비슷한 흐름으로 구현했었네

역시 직접 부딪혀보면서 리액트 훅폼을 많이 익혔다고 생각했는데 제대로 모르고 막 사용한것같다 ㅎㅎ..

그래도 전반적인 흐름을 알게되어 좋았고 다음에, 특히 라이브러리와 함께 쓸때는 Controller 사용도 적극적으로 해봐야겠다