react-hook-form - useController을 이용하여 여러 type의 input 만들기

김은호·2023년 2월 8일
2

Intro

개인 프로젝트를 진행하며 form 구현을 위해 react-hook-form을 이용하였다. 개발을 하던 중 input을 반복적으로 사용하여 공통 컴포넌트를 만들기 위해 공부를 하던 중, react-hook-form의 useController hook을 이용하면 공통 input 컴포넌트를 만들 수 있음을 알게되었다.

input element에는 여러가지 type이 있는데 useController을 사용할 때 그 모양새가 조금씩 다르다. 대표적인 세 가지 type을 구현한 코드를 여기서 정리하려고 한다.

type="text"

// common/Input.tsx

type TControl<T extends FieldValues> = {
  control: Control<T>;
  name: FieldPath<T>;
  rules?: Omit<
    RegisterOptions<T>,
    'valueAsNumber' | 'valueAsDate' | 'setValueAs' | 'disabled'
  >;
  icons?: JSX.Element;
  placeholder: string;
  type: string;
  errorMessage: string;
};

function Input({
  icons,
  control,
  name,
  rules,
  placeholder,
  type,
  errorMessage,
}: TControl<any>) {
  const {
    field: { value, onChange, onBlur },
  } = useController({ name, rules, control });
  const [isError, setIsError] = useState(true);

  useEffect(() => {
    if (errorMessage === undefined) {
      setIsError(false);
    } else {
      setIsError(true);
    }
  }, [errorMessage]);

  return (
    <Container iconExist={!!icons} isError={isError}>
      <input
        // value가 undefined이면 input 에러가 발생하므로
        value={value || ''}
        onChange={onChange}
        onBlur={onBlur}
        placeholder={placeholder}
        type={type}
      />
      <IconWrapper>{icons}</IconWrapper>
      <p>{errorMessage}</p>
    </Container>
  );
}

export default Input;
interface IForm {
 email: string; 
}
function Form() {
  const {
    handleSubmit,
    formState: { errors },
    setError,
    control,
  } = useForm<IForm>();
  
  return (
    <Container>
      <form onSubmit={handleSubmit(onSubmit)}>
        <InputWrapper>
          {/* Email */}
          <Input
            name="email"
            control={control}
            rules={{
              required: '이메일을 입력하세요',
              pattern: {
                value:
                  /^[0-9a-zA-Z]([-_.]?[0-9a-zA-Z])*@[0-9a-zA-Z]([-_.]?[0-9a-zA-Z])*.[a-zA-Z]{2,3}$/i,
                message: '이메일 형식이 아닙니다.',
              },
            }}
            placeholder="이메일"
            type="text"
            icons={<Email />}
            errorMessage={errors.email?.message as string}
          />
        </InputWrapper>
      </form>
    </Container>
  );
}

export default Form;

type="select"

// common/Selector.tsx

type TControl<T extends FieldValues> = {
  control: Control<T>;
  name: FieldPath<T>;
  rules?: Omit<
    RegisterOptions<T>,
    'valueAsNumber' | 'valueAsDate' | 'setValueAs' | 'disabled'
  >;
  icons?: JSX.Element;
  options?: string[];
  disabledOptions?: string[];
  errorMessage: string;
  lable?: string;
};

function Selector({
  icons,
  control,
  name,
  rules,
  options = [],
  disabledOptions = [],
  errorMessage,
  lable,
}: TControl<any>) {
  const {
    field: { value, onChange },
  } = useController({ name, rules, control });
  return (
    <Container iconExist={!!icons}>
      {lable && <span>{lable}</span>}
      <select value={value || ''} onChange={onChange}>
        {disabledOptions.map((option, index) => (
          <option key={index} value={option} disabled>
            {option}
          </option>
        ))}
        {options.map((option, index) => (
          <option key={index} value={option}>
            {option}
          </option>
        ))}
      </select>
      <IconWrapper>{icons}</IconWrapper>
      <p>{errorMessage}</p>
    </Container>
  );
}

export default Selector;
interface IForm {
 month: string; 
}
function Form() {
  const {
    handleSubmit,
    formState: { errors },
    setError,
    control,
  } = useForm<IForm>();
  
  return (
    <Container>
      <form onSubmit={handleSubmit(onSubmit)}>
		  <SelectWrapper>
            <Selector
              icons={<SelectDown />}
              name="month"
              control={control}
              rules={{ required: '선택해주세요' }}
              options={monthList}
              errorMessage={errors.month?.message as string}
            />
          </SelectWrapper>
      </form>
    </Container>
  );
}

export default Form;

type="radio"

개인적으로 제일 까다로웠다.
Radio는 여러 input이 모여 한 그룹을 형성하는 것이 일반적이므로 RadioGroup을 공통 컴포넌트로 만들었다.

위 두 유형과는 달리 onChange에 e.target.value로 값을 넘겨줘야했다. 안그러면 undefined가 나왔는데 이유는 아직 모르겠음..

type TControl<T extends FieldValues> = {
  control: Control<T>;
  name: FieldPath<T>;
  rules?: Omit<
    RegisterOptions<T>,
    'valueAsNumber' | 'valueAsDate' | 'setValueAs' | 'disabled'
  >;
  label?: string;
  errorMessage?: string;
  options?: { label: string; value: any; description: string }[];
};
function RadioGroup({
  control,
  name,
  rules,
  label,
  options,
}: TControl<any>) {
  const { field } = useController({ name, rules, control });
  const [checked, setChecked] = useState(false);
  return (
    <Conitaner>
      <p className="radio-label">{label}</p>
      <div className="radio-list-wrapper">
        {options?.map((option, index) => (
          <label key={index}>
            <input
              type="radio"
              checked={field.value === option.value}
              onChange={(e) => {
                field.onChange(e.target.value);
                setChecked(true);
              }}
              value={option.value}
            />
            <span>
              {option.label}
              <RadioDescription>{option.description}</RadioDescription>
            </span>
          </label>
        ))}
      </div>
      {!checked && (
        <Warning>
          <WarningIcon />
          <p>옵션을 선택해주세요.</p>
        </Warning>
      )}
    </Conitaner>
  );
}

export default React.memo(RadioGroup);
interface IForm extends FieldValues {
  largeBuildingType: string;
  buildingType: string;
  radioType: string;
}

function Form() {
  const [largeRoom, setLargeRoom] = useRecoilState(roomState);
  const {
    control,
    formState: { errors },
    watch,
  } = useForm<IForm>();
  };

  const onSubmit = () => {
   	/* .. */ 
  }
  
  return (
    <Container>
      <form>
        <RadioWrapper>
          <RadioGroup
            name="radioType"
            control={control}
            label="숙소 유형을 선택해주세요."
            options={roomTypeRadioOptions}
          />
          </RadioWrapper>
        <RegisterRoomFooter
          prevLink="/"
          nextLink="/room/register/bedrooms"
          onSubmit={onSubmit}
        />
      </form>
    </Container>
  );
}

export default Form;

외부 컴포넌트에서 submit하기

사실상 이 글을 쓴 메인 목적이다.

radio 유형에서 form에 handleSubmit을 등록하지 않고 Footer에 onSubmit 함수를 넘겨주고 있다.

원래는 form에 handelSubmit을 작성하였고, Footer에 버튼을 만들었으므로 클릭을하면 form에 등록된 이벤트가 실행될 줄 알았는데 아니었다.

react-hook-form 공식 사이트에서 handleSubmit에 대한 문서를 보니 다음과 같이 적혀있었다.

// It can be invoked remotely as well
handleSubmit(onSubmit)();

즉 굳이 form에만 handleSubmit을 등록할 필요 없고, 다른 컴포넌트에서도 handleSubmit을 등록할 수 있다.

// Footer.tsx

interface IProps {
  prevLink?: string;
  nextLink?: string;
  onSubmit: (data: any) => void;
}
function RegisterRoomFooter({ prevLink, nextLink, onSubmit }: IProps) {
  const { handleSubmit } = useForm();
  return (
    <Container>
      <Link className="back" href={prevLink || ''}>
        뒤로
      </Link>
      <Link href={nextLink || ''}>
        <button
          type="submit"
          onClick={() => {
            // 즉시실행함수 형태로 써야함!
            handleSubmit(onSubmit)();
          }}
        >
          계속
        </button>
      </Link>
    </Container>
  );
}

export default RegisterRoomFooter;

궁금한 점

만약 Footer의 상위 컴포넌트에서 두 개 이상의 form이 있다면 하위 컴포넌트인 Footer에서 handleSubmit을 호출하면 그 두개의 form을 구별할 수 있을까..?

1개의 댓글

comment-user-thumbnail
2024년 9월 24일

라디오를 어떻게 관리해야할지 막막했는데 도움이 됐습니다.
감사합니다.

답글 달기