D5ng

React

React Hook Form과 유사한 인터페이스 만들기

상태 관리를 통해 React Hook Form과 유사한 인터페이스의 훅 만들기 🚀

DH
DongHyun Lee·FE Developer
22 min read·November 09, 2024
thumnnail

이 글을 작성하는 이유는 일관성 있는 폼 관리가 쉽지 않았기 때문이다. 일관성 있는 폼에 대해 곰곰이 생각해본 결과, 그 해답은 라이브러리에 있을 것이라고 판단했다. 그래서 가장 많이 사용하는 React Hook Form (RHF)을 분석하고, 그 인터페이스를 구현하여 일관성 있는 폼을 만들었다. 또한, RHF를 사용하지 않았던 이유에 대해서도 내 생각을 적어보려고 한다.

제어 컴포넌트와 비제어 컴포넌트

제어 컴포넌트란 React에서 값이 컴포넌트의 상태나 부모 컴포넌트로부터 전달된 props에 의해 제어되는 컴포넌트를 말한다. 반면, 비제어 컴포넌트는 DOM에 직접 접근하여 값이나 동작을 제어하는 컴포넌트를 말한다. 제어 컴포넌트는 상태를 통해 값이 관리되기 때문에 상태 기반으로 입력 값을 쉽게 조작할 수 있다는 장점이 있다. 그러나 상태가 변경될 때마다 리렌더링이 발생하므로, 많은 DOM 요소가 렌더링될 경우 성능 저하가 발생할 수 있다.

비제어 컴포넌트는 React의 상태 대신 ref를 사용해 DOM 요소에 직접 접근하여 값을 제어하므로, 리렌더링이 발생하지 않는다. 따라서 많은 폼 필드를 다룰 때도 렌더링 성능에 대한 걱정이 덜하다. 그러나 비제어 컴포넌트는 실시간으로 입력 값을 검증하거나, 제어 컴포넌트처럼 상태를 기반으로 쉽게 조작하는 데에 어려움이 있다.

비제어 컴포넌트라고 해서 상태 관리를 전혀 하지 않는 것은 아니다. 제어 컴포넌트와 비슷한 방식으로 동작하게 하려면, 최소한의 상태 관리가 필요하다. 하지만, 이를 구현하는 것은 복잡하고 어려울 수 있기 때문에 RHF와 같은 라이브러리를 사용하기도 한다.

RHF에 대한 내 생각

폼을 관리하는 요소가 많을 때 RHF를 사용하는 것이 성능적으로 더 유리하다고 생각한다. 폼 필드가 적다면 리렌더링이 발생하더라도 성능에 큰 영향을 미치지 않겠지만, 폼 요소가 많아질수록 리렌더링으로 인한 성능 저하가 문제가 될 수 있기 때문이다. 그래서 많은 폼 필드를 관리할 때는 RHF가 성능적으로 더 나은 선택이라고 생각한다. 물론 편리함 때문에 RHF를 선택할 수도 있지만, 이로 인해 번들 사이즈가 증가하는 것은 성능에 좋지 않은 영향을 미칠 수 있다. 만약 빠른 개발을 목표로 한다면 이러한 타협이 필요할 것 같다.

RHF와 유사한 인터페이스 만들기

구현할 기능

RHF의 기능들을 참고하여 다음과 같은 것들을 구현할 예정이다.

  • formState(values, errors, touchedFields, isSubmitting, isValid): 폼의 상태를 관리하는 객체로, 입력된 값, 에러, 터치된 필드, 제출 상태 등을 추적
  • register(name, onChange, onBlur, validation): 각 폼 필드를 등록하고, 해당 필드의 이벤트와 유효성 검사를 처리하는 함수
  • handleSubmit: 폼 제출 시 호출되는 함수로, 유효성 검사를 통과하면 제출을 처리하는 역할
  • setValue: 특정 필드의 값을 설정하는 함수
  • setError: 특정 필드에 에러 메시지를 설정하는 함수
  • reset: 폼을 초기 상태로 되돌리거나 제출 후 입력된 값들을 모두 초기화하는 함수
// RHF의 간단한 기능
export default function HookForm() {
  const { register, handleSubmit, formState } = useForm<DefaultValues>({
    mode: "onBlur",
    defaultValues: {
      email: "",
      password: "",
    },
  })
 
  const submit = (data: DefaultValues) => {
    console.log(data)
  }
 
  return (
    <form onSubmit={handleSubmit(submit)}>
      <div>
        <label htmlFor="email">Email</label>
        <input type="text" id="email" {...register("email", emailValidation)} />
      </div>
      <div>
        <label htmlFor="password">Password</label>
        <input type="password" id="password" {...register("password", passwordValidation)} />
      </div>
      <button>Click</button>
    </form>
  )
}

useForm의 타입과 상태 정의하기

useForm을 구현할 때, 폼 필드를 관리하는 객체들의 타입을 신경 써야 한다. useForm의 인수는 객체 형태로 defaultValues를 받아야 하며, 타입 추론도 가능해야 한다. 이를 위해, RHF처럼 제네릭을 사용하고 기본 타입을 지정해주면 defaultValues에 대한 타입을 자동으로 추론할 수 있다.

export type FieldValues = Record<string, any>
 
interface UseFormParams<T> {
  defaultValues: T
}
 
type FieldState<TFieldValues, FieldStateType> = Partial<Record<keyof TFieldValues, FieldStateType>>
 
interface UseFormState<TFieldValues> {
  values: TFieldValues
  touchedFields: FieldState<TFieldValues, boolean>
  errors: FieldState<TFieldValues, string>
  isSubmitting: boolean
  isValid: boolean
  defaultValues: Readonly<TFieldValues>
}
 
export default function useForm<TFieldValues extends FieldValues = FieldValues>({
  defaultValues,
}: UseFormParams<TFieldValues>): UseFormReturn<TFieldValues> {
  const [formState, setFormState] = useState<UseFormState<TFieldValues>>({
    values: defaultValues,
    touchedFields: {},
    errors: {},
    isSubmitting: false,
    isValid: false,
    defaultValues,
  })
}

참고로 네이밍은 RHF의 네이밍을 따랐으며, UseFormProps 대신 UseFormParams로만 바꾸었다. 이처럼 제네릭과 기본 타입을 사용하면, 타입의 안정성과 유연성을 모두 확보할 수 있다.

FieldState에서 Partial 타입을 사용한 이유는 touchedFields와 errors 같은 상태가 처음부터 모든 필드에 대해 관리되지 않기 때문이다. 폼 필드들이 상호작용할 때만 해당 상태가 추가되므로, Partial을 사용하여 필요한 시점에 필드의 상태가 동적으로 추가되도록 유연하게 관리할 수 있다.

register 구현하기

RHF의 register는 비제어 컴포넌트로 동작하기 때문에 value 값 대신 name, onChange, onBlur와 같은 핸들러만 반환한다. 그러나 제어 컴포넌트로 동작하기 위해서는 value를 추가해야 한다. 또한, register의 첫 번째 인수는 폼 필드에 대한 타입 추론이 가능해야 하며, 두 번째 매개변수로는 유효성 검사 객체를 옵션으로 전달할 수 있도록 구현해야 한다.

export type UseFormRegister<TFieldValues extends FieldValues> = <TFieldName extends keyof TFieldValues>(
  name: TFieldName,
  options?: RegisterOptions,
) => UseFormRegisterReturn<TFieldName, TFieldValues>
 
export type UseFormRegisterReturn<TFieldName extends keyof TFieldValues, TFieldValues> = {
  name: TFieldName
  onChange: ChangeHandler
  onBlur: ChangeHandler
  value: TFieldValues[TFieldName]
  ref: RefCallback
}
 
export type RegisterOptions = Partial<{
  required: { value: boolean; message: string }
  min: { value: number; message: string }
  max: { value: number; message: string }
  pattern: { value: RegExp; message: string }
}>
 
const validateOptions = useRef<Partial<Record<keyof TFieldValues, RegisterOptions>>>({})
 
const register: UseFormRegister<TFieldValues> = (name, options) => {
  if (options && !validateOptions.current[name]) {
    validateOptions.current[name] = options
  }
 
  return {
    name,
    value: formState.values[name],
    onChange: handleChange,
    onBlur: handleChange,
    ref: setFieldsRef(name),
  }
}

register 함수는 각 Input에 name, onChange, onBlur, value 값을 할당한다. 유효성 검사를 진행할 때 validateOptions.current[name] = options 코드를 포함하지 않으면, 해당 필드에 대해서만 유효성 검사가 이루어진다. 폼 제출 시 모든 필드에 대한 유효성 검사를 수행하려면, useRef를 사용하여 빈 객체를 만들고, 각 필드의 유효성 검사 옵션을 저장하도록 구현했다.

이렇게 구현하면, 개발자 도구를 사용해 button의 disabled 속성을 제거하더라도 유효성 검사가 정상적으로 진행되어, 안정적으로 폼을 관리 할 수 있다. 또한, button을 disabled 처리하지 않는 경우도 많다. 물론 개발자나 해커가 아닌 이상 저런 방법을 시도하진 않겠지만 혹시 모를 상황에 대비하는 편이다.

register 함수에서 onChange와 onBlur에 동일한 handleChange 함수로 이벤트 핸들러를 설정한 것은 실수가 아니다. RHF에서는 이벤트 타입에 따라 자동으로 유효성 검사를 실행하도록 되어 있기 때문에, 이와 같은 방식으로 작성하면 중복된 코드를 줄일 수 있다. 또한, 이벤트가 발생하는 시점에 따라 동작해 onTouched와 같도록 구현했다. ref를 사용한 이유는 추후에 error가 발생한 필드에 focus를 처리하기 위함이다.

handleSubmit 구현하기

RHF에서는 handleSubmit을 제공하여 내부적으로 유효성 검사를 자동으로 처리한다. 이 함수는 유효성 검사를 통과했을 때와 실패했을 때 각각의 콜백을 실행한다. 또한, isSubmitting 상태를 함께 관리할 수 있어, 제출 중인 상태를 쉽게 추적할 수 있다. 따라서 이와 유사하게 만든다면 다음과 같은 코드가 될 것이다.

export type SubmitHandler<TFieldValues> = (data: TFieldValues) => Promise<void> | void
 
export type SubimtErrorHandler = (error: Error) => Promise<void> | void
 
export type UseFormHandleSubmit<TFieldValues extends FieldValues> = (
  onValid: SubmitHandler<TFieldValues>,
  onInvalid?: SubimtErrorHandler,
) => (event: FormEvent) => Promise<void>
 
const handleSubmit: UseFormHandleSubmit<TFieldValues> = (onSubmit, onError) => async (event) => {
  event.preventDefault()
  const errors = validateForm(formState.values, validateOptions.current)
 
  if (hasAnyError(errors)) {
    handleError(errors)
    return
  }
 
  setFormState((prevFormState) => ({ ...prevFormState, isSubmitting: true }))
 
  try {
    await onSubmit(formState.values)
  } catch (error) {
    const err = error as Error
    if (onError) {
      onError(err)
    }
  } finally {
    setFormState((prevFormState) => ({ ...prevFormState, isSubmitting: false }))
  }
}

커링 함수를 활용하여 handleSubmit이 콜백 함수를 받도록 구현하여 RHF와 유사한 인터페이스를 맞췄다. 폼을 제출할 때, 여러 필드 중 하나라도 에러가 발생하면 제출이 중단되도록 했다. 이렇게 함으로써 폼을 관리하는 모든 곳에서 유효성 검사를 일관되게 적용할 수 있어, 중복을 줄일 수 있다. 또한, isSubmitting 상태도 관리하여 버튼의 비활성화 및 로딩 상태를 제어함으로써 UX 개선에도 도움을 줄 수 있다.

RHF에서는 onError를 통해 formState.errors를 전달하지만, 나는 이 객체가 불필요하다고 판단하여 별도의 에러 객체를 직접 전달하도록 구현했다. 이유는 명확하지 않지만, RHF는 onError에서 단순히 formState.errors를 전달하며, 별도의 에러 객체는 제공하지 않는다. 실제로 TypeScript 예제에서도 onSubmit 내부에서 try-catch를 통해 에러를 별도로 핸들링하도록 작성되어 있어, API 호출과 같은 비동기 작업 중 발생하는 에러를 명확히 처리할 수 있도록 권장하고 있다.

handleSubmit function will not swallow errors that occurred inside your onSubmit callback, so we recommend you to try and catch inside async request and handle those errors gracefully for your customers.
handlerSubmit 함수는 onSubmit 콜백 내에서 발생한 오류를 삼키지 않으므로 비동기 요청 내에서 포착하여 고객을 위해 해당 오류를 적절하게 처리하는 것이 좋습니다.

중간 점검

지금까지 작성한 내용을 기반으로 직접 만든 폼과 RHF를 사용한 폼을 비교해 보겠다.

// 인터페이스를 유사하게 만든 Form
export default function Form() {
  const { formState, register, handleSubmit } = useForm<DefaultValues>({ defaultValues })
 
  const onSubmit = async (data: DefaultValues) => {
    console.log(data)
  }
 
  return (
    <form onSubmit={handleSubmit(onSubmit)}>
      <div>
        <label htmlFor="email">Email</label>
        <input type="text" id="email" {...register("email", emailValidate)} className="border border-slate-700" />
        {formState.errors.email && <p>{formState.errors.email}</p>}
      </div>
      <div>
        <label htmlFor="password">Password</label>
        <input
          type="password"
          id="password"
          {...register("password", passwordValidate)}
          className="border border-slate-700"
        />
        {formState.errors.password && <p>{formState.errors.password}</p>}
      </div>
      <button disabled={!formState.isValid}>Submit</button>
    </form>
  )
}
 
// RHF를 사용한 폼
export default function HookForm() {
  const { formState, register, handleSubmit } = useForm<DefaultValues>({
    mode: "onTouched",
    defaultValues,
  })
 
  const onSubmit = async (data: DefaultValues) => {
    console.log(data)
  }
 
  return (
    <form onSubmit={handleSubmit(onSubmit)}>
      <div>
        <label htmlFor="email">Email</label>
        <input type="text" id="email" {...register("email", emailValidate)} className="border border-slate-700" />
        {formState.errors.email && <p>{formState.errors.email.message}</p>}
      </div>
      <div>
        <label htmlFor="password">Password</label>
        <input
          type="password"
          id="password"
          {...register("password", passwordValidate)}
          className="border border-slate-700"
        />
        {formState.errors.password && <p>{formState.errors.password.message}</p>}
      </div>
      <button>Submit</button>
    </form>
  )
}

첫 번째는 직접 구현한 폼이고, 두 번째는 RHF를 사용한 폼이다. RHF에서는 mode 옵션에 따라 폼의 유효성 검사 타이밍을 설정할 수 있지만, 내 경험상 대부분의 경우 onTouched 모드를 사용하기 때문에 커스텀 폼에서는 mode 옵션을 구현하지 않았다. 에러 메세지를 관리하는 부분빼고는 거의 똑같다.

setValue, setError 구현하기

setValue는 폼의 특정 필드 값을 수동으로 업데이트하기 위해 필요하다. 특히 Select와 같은 커스텀 컴포넌트에서 유용하게 사용된다. 값이 변경될 때마다 setValue를 호출하여 필드 값을 수동으로 업데이트할 수 있다.

또한, setError는 비동기 요청 후 발생한 에러를 폼 상태에 반영하기 위해 사용된다. 예를 들어 서버에서 받은 유효성 검사 결과나 에러 메시지를 setError로 설정하여 사용자에게 표시할 수 있다.

export type UseFormSetValue<TFieldValues extends FieldValues> = <TFieldName extends keyof TFieldValues>(
  field: TFieldName,
  value: any,
) => void
 
export type UseFormSetError<TFieldValues extends FieldValues> = <TFieldName extends keyof TFieldValues>(
  field: TFieldName,
  message: string,
) => void
 
const setValue: UseFormSetValue<TFieldValues> = useCallback((field, value) => {
  setFormState((prevFormState) => ({ ...prevFormState, values: { ...prevFormState.values, [field]: value } }))
}, [])
 
const setError: UseFormSetError<TFieldValues> = useCallback((field, message) => {
  setFormState((prevFormState) => ({ ...prevFormState, errors: { ...prevFormState.errors, [field]: message } }))
}, [])

reset 구현하기

reset은 폼을 초기 상태로 되돌리거나 제출 후 입력된 값들을 모두 초기화할 때 사용하는 함수다. 흔히 handleSubmit 함수 내에서 폼 제출 후 값을 초기화하는 방법을 떠올릴 수 있지만, 비동기 요청 후 setError를 사용하면 폼의 오류 상태까지 초기화되는 문제가 발생할 수 있다. 이를 방지하기 위해 폼을 사용하는 쪽에서 직접 reset을 호출하여 초기 상태로 되돌려야 한다.

const reset = useCallback(() => {
  setFormState((prevFormState) => ({
    values: prevFormState.defaultValues,
    touchedFields: {},
    errors: {},
    isValid: false,
    isSubmitting: false,
    defaultValues: prevFormState.defaultValues,
  }))
}, [])

최종 비교

직접 구현한 폼과 RHF의 폼을 비교해보면, 인터페이스와 동작 모두 매우 유사하다. 이제 Form을 사용하는 모든 곳에서 일관성 있게 작성할 수 있으며, 유지보수 측면에서도 훨씬 관리가 용이해졌다.

// 인터페이스를 유사하게 만든 Form
export default function Form() {
  const { formState, register, handleSubmit, setError, setValue } = useForm<DefaultValues>({ defaultValues })
 
  const onSubmit = async (data: DefaultValues) => {
    console.log(data)
    try {
      await fetch("https://jsonplaceholder.typicode.com123/todos/1")
        .then((response) => response.json())
        .then((json) => console.log(json))
    } catch (error) {
      setError("email", "에러가 발생했어요")
    }
  }
 
  useEffect(() => {
    setValue("email", "test@gmail.com")
  }, [setValue])
 
  return (
    <form onSubmit={handleSubmit(onSubmit)}>
      <div>
        <label htmlFor="email">Email</label>
        <input type="text" id="email" {...register("email", emailValidate)} className="border border-slate-700" />
        {formState.errors.email && <p>{formState.errors.email}</p>}
      </div>
      <div>
        <label htmlFor="password">Password</label>
        <input
          type="password"
          id="password"
          {...register("password", passwordValidate)}
          className="border border-slate-700"
        />
        {formState.errors.password && <p>{formState.errors.password}</p>}
      </div>
      <button disabled={!formState.isValid || formState.isSubmitting}>Submit</button>
    </form>
  )
}
 
// RHF를 사용한 폼
export default function HookForm() {
  const { formState, register, handleSubmit, setError, setValue } = useForm<DefaultValues>({
    mode: "onTouched",
    defaultValues,
  })
 
  const onSubmit = async (data: DefaultValues) => {
    console.log(data)
    try {
      await fetch("https://jsonplaceholder.typicode.com123/todos/1")
        .then((response) => response.json())
        .then((json) => console.log(json))
    } catch (error) {
      setError("email", { message: "에러가 발생했어요" })
    }
  }
 
  useEffect(() => {
    setValue("email", "test@gmail.com")
  }, [setValue])
 
  return (
    <form onSubmit={handleSubmit(onSubmit)}>
      <div>
        <label htmlFor="email">Email</label>
        <input type="text" id="email" {...register("email", emailValidate)} className="border border-slate-700" />
        {formState.errors.email && <p>{formState.errors.email.message}</p>}
      </div>
      <div>
        <label htmlFor="password">Password</label>
        <input
          type="password"
          id="password"
          {...register("password", passwordValidate)}
          className="border border-slate-700"
        />
        {formState.errors.password && <p>{formState.errors.password.message}</p>}
      </div>
      <button disabled={!formState.isValid || formState.isSubmitting}>Submit</button>
    </form>
  )
}

만들면서 느낀 점.

라이브러리의 소스 코드를 해석하는 것이 많이 힘들었다. RHF는 비제어 컴포넌트로 동작하며, 추상화 수준이 높아 해석하기가 쉽지 않았다. 처음에는 "이렇게 구현되지 않았을까?"라는 생각으로 접근했지만, 예상보다 막히는 부분이 많았다. 특히 신기했던 부분은 onChange와 onBlur를 각각의 이벤트 핸들러 함수로 사용하는 대신, 하나의 이벤트 핸들러 함수로 처리한다는 점이었다. RHF는 event.type에 따라 동작하도록 되어 있고, 중복된 코드를 줄이며 유효성 검사 타이밍도 세밀하게 제어된다. 이 부분에서 내가 배운 것만을 활용하려 했다는 한계를 느꼈고, 라이브러리 소스 코드를 보며 생각의 폭을 확장해야겠다는 생각이 들었다.

타입스크립트를 사용하면서 제네릭 타입에 대해서도 많은 것을 배웠다. 내가 사용한 제네릭은 겉멋처럼 느껴졌지만, RHF에서의 제네릭 타입은 정말 유연하게 작성되어 있고, 타입 추론도 잘 돼서 놀랐다. 타입을 구현하면서 소스 코드를 참고했지만, 이렇게까지 유연하게 사용할 수 있다는 점에서 큰 인상을 받았다.

RHF를 분석하면서 callback ref를 사용하는 방식을 처음 접했다. 찾아보니, callback ref는 요소가 마운트되었을 때 바로 실행되어 useEffect 같은 추가적인 훅 없이도 초기화 작업을 수행할 수 있다고 한다. 또한, RHF에서는 기본적으로 에러가 발생한 필드에 focus를 자동으로 맞추지 않지만, 에러 발생 시 해당 필드로 focus가 처리되도록 구현하여 UX를 개선했다. (더 찾아봐보고 RHF에 이슈를 남기면 좋을것 같다는 생각도 든다)

현재 hook 안에 코드가 다소 밀집되어 있어 가독성이 떨어질 수 있다는 생각이 든다. useReducer를 사용하여 Flux Pattern을 적용하면, 상태 업데이트 로직을 액션별로 분리해 가독성을 높일 수 있고, 코드가 더 구조화되며, 디버깅도 수월해질 것 같다.

깃헙 레포 (최종 코드)