React Hook Form과 Validation

2023. 4. 9. 22:12개발/오픈소스

이번에 서비스를 새로 리뉴얼하면서 Jquery에서 Next.js 기반으로 서비스를 새로 구성했다.

서비스 특성상 사용자가 작성하는 Form과 데이터를 보여주는게 핵심적인 기능인데, 안정적인 서비스와 관리를 위해 필수적으로 사용해야하는 오픈소스중 하나가 React Hook Form 이였는데, 어떤식으로 사용했는지, 사용하는데 불편한점이나 처음부터 알았으면 좋았을 것 같은 부분들을 중심으로 이야기를 풀어가려한다.

React Hook Form 이란?

React에서 전반적인 Form Input 관리를 위해 DX, UX, 퍼포먼스 중심으로 설계된 오픈소스.
여러개의 input을 복합적으로 사용하거나 복잡한 Input validation을 처리할 때 특히 유용하게 사용할 수 있다.

어떤식으로 사용하나요?

React Hook Form은 최상단의 FormProvider 를 중심으로 동작한다.

아래 예시처럼 FormProvider선언 후 reigster로 input을 초기화 시켜준다.

import { FormProvider, useForm } from "react-hook-form";
import LabelInput from "./components/LabelInput";
import "./App.css";

function App() {
  const method = useForm();

  const onSubmitHandler = (data) => {
    console.log(data); // form data
  };

  return (
    <div className="App">
      <FormProvider {...method}>
        <form onSubmit={method.handleSubmit(onSubmitHandler)}>
          <div className="flex items-center gap-3">
            <label htmlFor="id">이름</label>
            <input className="p-2" {...method.register("name")} />
          </div>
        </form>
      </FormProvider>

      <div className="mt-4">{method.watch("name")}</div>
    </div>
  );
}

export default App;

FormProvider로 감싼 자식 컴포넌트에서는 context의 개념으로 데이터를 가져와서 쳐리할 수 있다.

const Child1 = () => {
  // useFormContext로 FormProvider에서 register를 받아 input 등록
  const { register } = useFormContext();

  return <input {...register("child1")} />;
};

const Child2 = () => {
  // 다른 Input watch 가능
  const { watch } = useFormContext();

  return <div>{watch("child1")}</div>;
};

const Parent = () => {
  const method = useForm();

  return (
    <FormProvider {...method}>
      <Child1 />
      <Child2 />
    </FormProvider>
  );
};

register

registeronChange, onBlur, name, ref 를 반환하고 각각을 override 할 수 있는데, 이중 ref를 override하는 방식이 조금은 특이하다.
onChange, onBlur는 register를 초기화시킬때 option으로 초기화 시킬 항목을 정해주면 되지만,

const App = () => {
  const regist = register("example1", {
    onChange: (e) => {
      // override onChange
    },
    onBlur: (e) => {
      // override onBlur
    },
  });

  return <input {...regist} />;
};

ref는 option에서 override 시킬 수 있는 항목은 따로 없고, 아래와 같은 방식으로 진행한다.

const inputRef = useRef(null);
const { register } = useFormContext();

const { ref, ...regist } = register("fileInput", {
  require: {
    // for validation
    value: true,
    message: "필수로 입력해주세요",
  },
  onChange: (e) => {
    // override onChange
  },
  onBlur: (e) => {
    // override onBlur
  },
});

return (
  <>
    <input
      {...regist}
      ref={(e) => {
        ref(e);
        inputRef.current = e;
      }}
    />
  </>
);

필자는 customFileInput을 만들기 위해 ref를 override시키는게 필요했는데, 한가지 문제점이 있었다.

react-hook-form은 input에 설정한 validation이 통과되지 않은채로 form이 submit 될 때,
에러가 발생한 input으로 focus시켜주는 shouldFocusError의 default value가 true이다.

그런데 위 방식으로 ref를 override시킨 input만 해당 기능이 동작하지 않는 현상이 생겼다.
그래서 focus용 input을 하나 더 만들어서 해당 input에 ref를 넣어서 해결하려 했지만 ref를 사용해서 파일 데이터를 가져와야하기 때문에 해결책이 될 수 없었고, 다른 방법을 모색하는 중이다.

validation

React hook form은 input validation을 처리할 수 있도록 도와준다.

const InputComp = () => {
  const { register } = useFormContext();

  return (
    <div className="flex">
      <input
        {...register("name", {
          require: {
            // for validation
            value: true,
            message: "필수로 입력해주세요",
          },
        })}
      />
      <input id="isUse" type="checkbox" />
    </div>
  );
};

React Hook Form을 도입하게 된 가장 큰 이유가 input validation이였는데, 많은 input의 validation을 종단에서 처리하려고 했을때는 에로사항이 많았다.
하나의 FormProvider에 컴포넌트 다른 수십개의 input을 관리하는건 너무 힘들었고,
이미 react-hook-form에서 validation기능을 제공하는데 yup, joi와 같은 부가적인 관리포인트가 필요할까? 라고 생각했던 프로젝트 초기의 생각은 완전 틀렸다.

이런 schema유효성을 검증해주는 library는 최상단에서 ID값으로 유효성 검증을 관리할 수 있다.
따라서 컴포넌트 종단까지 validation option을 내려서 관리하는 prop drilling도 막을 수 있고, 코드의 복잡성도 확실히 낮아졌다.
yup에서 제공하는 when, test, transform, cast등과 같은 validation utility도 훌륭해서
input이 입력된 조건이나 기타 외부적인 조건에 따라 validation이 변경되어야 할 때 굉장히 유용하게 사용할 수 있었다.

// simple example - use yup

const InputComp = () => {
  const { register } = useFormContext();

  return (
    <div className="flex">
      <input {...register("name")}/>
      <input id="isUse" type="checkbox" />
    </div>
  );
};

const validation = () => {
  return yup.object().shape({
    name: yup.string().when("isUse", {
      is: true,
      then: (schema) => schema.require("필수 값 입니다"),
    }),
  });
};

const Form = () => {
  const method = useForm({
    resolver: yupResolver(validation());
  });

  const onSubmitHandler = (data) => {
    console.log(data); // form data
  }

  return <FormProvider {...method}>
    <form onSubmit={method.handleSubmit(onSubmitHandler)}>
      <InputComp />
    </form>
  </FormProvider>
}
// simple example - not use yup

const InputComp = () => {
  const { register, watch } = useFormContext();

  return (
    <div className="flex">
      <input
        {...register("name", {
          require: {
            // for validation
            value: watch('isUse'),
            message: "필수로 입력해주세요",
          },
        })}
      />
      <input id="isUse" type="checkbox" />
    </div>
  );
};

const Form = () => {
  const method = useForm({
    resolver: yupResolver(validation());
  });

  const onSubmitHandler = (data) => {
    console.log(data); // form data
  }

  return <FormProvider {...method}>
    <form onSubmit={method.handleSubmit(onSubmitHandler)}>
      <InputComp />
    </form>
  </FormProvider>
}

위 예제들의 코드가 너무 간단하고 컴포넌트의 깊이가 1차원적이라 크게 체감되지 않을 수 있어서 실제 사용한 경험을 풀어보자.

회원가입 3단계가 있다고 가정하자

  1. 이용약관 동의
  2. 이름, ID, 실명인증, 비밀번호 등 입력
  3. 추가정보 입력

이때 회원가입 request는 모든 절차가 마무리 될 때 1회 요청해야하고 모든 input이 채워진 상태로 요청되어야한다.
따라서 최상단 FormProvider를 기반으로 모든 유효성 검사를 조정해야하는데,
yup을 사용하지 않는다면 각 컴포넌트의 종단에서 register option을 관리해야하고,
이때 공통 validation을 처리하려면(비밀번호, 비밀번호 확인 등) 이 또한 종단에서 관리되어야 한다.
이렇게 되면 추후에 컴포넌트를 나누고 리팩토링 할 때 validation 로직을 신경써야 하기 때문에 관심사를 분리할 수 있어야 한다.
그걸 훌륭하게 도와주는게 yup과 같은 스키마 유효성 검사 오픈소스들이고 꼭! 사용하길 바란다.