form 리팩토링하기 - UI와 RHF 로직 분리

jh·2024년 4월 10일

개발 당시 중점적으로 생각한 사항

처음 form 부분을 맡았을 당시에는 그닥 어렵지 않은 것이라 생각했다.

하지만 나의 케이스는

  1. 한 Form 안에서 다루는 input이 7개가 넘어간다. 심지어 input이 아닌것들도 form 안에 포함되어야 한다(이 부분은 나중에...) 렌더링 문제와, input의 추가에도 대응할 수 있어야 한다

  2. validation 또한 달라질 여지가 굉장히 많았다.
    최대한 프론트단에서 validation 처리를 통해 서버 요청을 최소화 해야하고, 유저의 선호도에 따라 언제든지 변경에 대응할 수 있어야 하는 부분이다

  3. submit 시에 서버에 보내야 하는 데이터 구조와 현재 다루고 있는 데이터의 구조가 달라서 변환이 필요하다

useFormContext

다행히도 RHF에서 비제어 방식으로 input을 관리해주고 있고, 이 밖에도 다양한 처리를 통해 생각보다 과도한 렌더링 문제는 걱정하지 않아도 되었다.
그래서 내가 중점적으로 생각한 것은 코드의 가독성이었다.

  • 코드가 너무 길지 않았으면 좋겠다
    - 이를 위해서 props를 최소화 하고싶다

props로 넘겨주지 않고도 상태를 공유할 수 있는 가장 간단한 방법은 Context API인데, RHF에서 useFormContext 라는 API를 통해 제공하고 있다

그래서 위의 예시처럼 useForm에서 리턴된 register 라는 메서드를 input에 바로 부착하지 않아도

//자식 컴포넌트
const {register} = useFormContext()

return <input {...register}/>

이렇게 사용이 가능하다.

기존 코드

const ProjectInputBox = ({
  name,
  label,
  footer,
  children,
  width,
}: ProjectInputBoxProps) => {
  const {
    register,
    formState: { errors },
  } = useFormContext<ProjectFormValues>()

  return (
    <Flex
      flexDir="column"
      gap="5px"
      width={width}>
      <label htmlFor={name}>
        <Text
          {label}
        </Text>
      </label>
      <ErrorMessage
        name={name}
        errors={errors}
        render={({ message }) => <ErrorText message={message} />}
      />
      {isValidElement(children) &&
        cloneElement(children as ReactElement<InputElementProps>, {
          id: name,
          ...register(name, projectInputRegister[name]),
        })}

      {footer && (
        <Text
          fontSize="sm"
          color="grey">
          {footer}
        </Text>
      )}
    </Flex>
 <ProjectInputBox
            name="endDate"
            label="종료일"
            width="30rem">
            <Input type="date" />
 </ProjectInputBox>

간단하게 설명하면 name 이라는 props만 넘겨주면, 알아서 ...register를 children으로 오는 컴포넌트의 props에 등록해준다

<children value={value} ref={ref} onChange={onChange} 
          onBlur={onBlur}.../>

name 으로 올 수 있는 값들과 유효성과 관련된 조건들은 타입과 상수화를 해주었다

좋은 점

  • 외부에서는 컴포넌트가 어떻게 보일지만 관리하고, name props만 넘겨주면 RHF와 관련된 것들은 useFormContext 를 통해 알아서 처리된다
  • name, projectInputRegister[name] 을 통해 휴먼 에러를 방지할 수 있다
    예를 들어
<input register('안녕',projectInputRegister['아녕'])/>

외부에서 이렇게 정의할 오류를 줄일 수 있다

단점

  • 컴포넌트의 UI는 사용하고 싶지만 해당 컴포넌트의 Context 관련 메서드는 사용하고 싶지 않을 경우에 대응할 수 없음
  • 한마디로 분리가 잘 안되어있다는 뜻

앞서서 말했듯이 내 Form에서는 input 말고도 다른 컴포넌트들 또한 관리하고 있어서, register 를 등록해줄 경우 (value,ref,onChange,onBlur...) 아예 오류가 발생한다

UI와 RHF 로직 분리

  • 기존에 만들어 놓았던 InputBox 라는 합성 컴포넌트를 사용하였고, Content 부분을 children을 통해 외부에서 주입하도록 하였다
const ProjectInputBox = ({
  name,
  label,
  footer,
  children,
}: ProjectInputBoxProps) => {
  return (
    <InputBox
      id={name}>
      <InputBox.Header name={name}>
        <Flex gap="5px">
          <Text
            fontSize="md"
            as="b">
            {label}
          </Text>
        </Flex>
      </InputBox.Header>
      <InputBox.Content>{children}</InputBox.Content>
      {footer && <InputBox.Footer text={footer} />}
    </InputBox>
  )
}

이제 해당 컴포넌트 내부에서 RHF 관련 코드가 제거되었고

  <ProjectInputBox
        label="제목"
        name="name">
     <input {...register('name')} />
  </ProjectInputBox>

이런 식으로 children을 통해 input + RHF register를 외부에서 주입할 수 있도록 하였다

에러 시 메시지 출력

RHF에서는 에러(유효성 검사 통과 X) 발생 시에 출력할 메시지를 따로 등록할 수 있다

...register('name',{required : '이름은 필수입니다'})

이 메시지를 받기 위해서는 errors라는 객체의 message에 접근해야 하는데

errors[name]?.message

어떻게 보면 좀 지저분? 해지는 것 같아서
RHF에서 만든 @hookform/error-message 라는 패키지에서 나온 ErrorMessage라는 컴포넌트를 사용하고 있었다

 <ErrorMessage
        errors={errors}
        name="singleErrorInput"
        render={({ message }) => <p>{message}</p>}
      />
  • errors 객체를 props로 전달하면, 해당 name에 해당하는 에러 발생 시 등록된 message를 렌더링해준다

굉장히 편하고, 선언적으로 에러에 대한 메시지를 보여줄 수 있다는 생각에 사용하고 있었는데
이제는 Context 를 제거했기 때문에, 해당 컴포넌트를 렌더링하기 위해서는 errors 라는 객체를 props 로 받아야 했는데 뭔가 이 점이 좀 별로였다..

생각해보면 ProjectInputBox 라는 컴포넌트에서 사실 에러 객체에 대한 정보를 알 필요는 없다.
단순히 에러에 대한 메시지를 렌더링 하는 것만으로 역할은 충분히 하지 않나.. 라는 생각이 들었다

errors? : string | FieldErrors

{typeof errors === "string" ? (
            <ErrorText message={errors} />
          ) : (
            <ErrorMessage
              name={name}
              errors={errors}
              render={({ message }) => (
                <ErrorText message={message} />
              )}></ErrorMessage>
          )}

일단은 이런 식으로 유니온 타입을 통해 객체/단순 문자열 두개가 올 수 있도록 허용해 놓고, 좀 더 생각해 본 뒤 하나로 통일하는 방향으로 가야겠다.

0개의 댓글