React Hook Form을 이용한 폼관리

minseok baek·2024년 10월 7일

프로젝트

목록 보기
18/20

이전글에서 제어 컴포넌트의 문제점을 보완하기 위해 React Hook Form에 대해 알아보았습니다.

React Hook Form이란?

React 개발자들을 위해 쉽게 폼을 구축할 수 있도록 지원하는 라이브러리입니다.

React Hook Form의 주요 장점은 다음과 같습니다:

  • 간단한 폼 생성검증 기능
  • 빠른 속도깔끔한 코드 구조
  • 코드량이 적어 Formik이나 Redux Form보다 효율적
  • 리렌더링 최소화로 부드러운 사용자 경험 제공

주요 개념에 대해서 알아보기

useForm Hook

폼 관리를 하기 위한 기본 훅

  • 사용법 : const { register, handleSubmit, formState } = useForm(); 폼 상태와 관련된 메서드와 객체를 반환합니다.
  • 기능: 입력 필드 등록, 폼 제출 처리, 상태 추적 등을 제공합니다.

register

폼 필드를 등록하는 메서드

  • 사용법 : register('fieldName', {validationRules}) 입력 필드를 등록하고 유효성 검사 규칙을 정의합니다.
  • 기능 : 입력 값 추적, 유효성 검사 및 오류 관리 기능을 제공합니다.

handleSubmit

폼 제출 이벤트를 처리하는 메서드

  • 사용법 : onSubmit={handleSubmit(onSubmit)} onSubmit 함수를 인자로 받아 사용합니다.
  • 기능 : 유효성 검사를 수행하고 검증된 데이터를 처리합니다.

control

폼 상태를 관리하고 입력 필드를 등록하는데 필요한 여러 메서드와 속성을 제공

formState

현재 폼의 상태를 나타내는 객체

  • errors : 유효성 검사에서 발생한 오류
  • isValid : 현재 폼의 유효성 검사 결과 (불리언 값)
  • isDirty : 입력 값이 초기값과 비교하여 변경되었는지 여부
  • isSubmitting : 폼이 제출 중인지 여부

Validation

입력 필드에 대한 유효성 검사 규칙을 설정하는 기능

  • 사용법 : register 메서드에 규칙을 추가하여 필드의 유효성을 검사할 수 있습니다.
  • 예시 :
<input 
  {...register('username', { required: true, minLength: 3 })} 
/>
  • required : 필드가 비어 있지 않은지 확인합니다.(특정 필드를 필수 입력으로 설정)

동적폼 관리는 어떻게??

동적 폼 관리는 사용자의 요구에 따라 입력 필드를 유동적으로 추가하고 제거하는 과정을 말합니다. React Hook Form에서는 useFieldArray 훅을 사용하여 동적 폼을 쉽게 구현할 수 있습니다.

useFiledArray

useFieldArray는 React Hook Form에서 동적 폼 필드를 관리하기 위한 훅

  • 입력 필드를 동적으로 추가하거나 제거할 수 있습니다.
  • 여러개의 입력 필드를 배열 형태로 그룹화하여 관리할 수 있습니다.
  • 각 필드에 대해 유효성 검사를 설정할 수 있습니다.
  const {
    register,
    control,
    handleSubmit,
    reset,
    formState: { errors },
  } = useForm<coverLetterType.ICoverLetter>({
    resolver: zodResolver(coverLetterModel.schema),
    defaultValues: {
      title: '',
      qnaList: [],
    },
  });

  const { fields, append, remove } = useFieldArray({
    control,
    name: 'qnaList',
  });

추가하고 싶은 질문이 있다면 append()를 호출하고,
필요 없어진 질문은 remove()로 삭제하면 됩니다.

  • fields : qnaList를 표현하는 fields 입니다.
  • append : qnaList의 배열 끝에 새로운 필드를 추가해줍니다.
  • remove : qnaList의 배열에서 특정 필드를 삭제 해줍니다.
  • prepend : qnaList의 배열의 시작에 새로운 필드를 추가해줍니다.

useFieldArray를 사용하여 리듀서와 같은 상태 관리를 고민할 필요 없이 동적 폼을 쉽게 구현하고 관리할 수 있었습니다. 위의 코드에서 확인할 수 있듯이 스키마 검증도 손쉽게 설정할 수 있습니다. 각 필드에 개별적으로 룰을 적용할 수도 있지만, 저는 Zod를 사용해 스키마 모델 정의를 분리하였습니다. Zod에 관한 내용은 좀 더 아래에서 자세히 다루겠습니다.

외부 UI 라이브러리 연동은 어떻게 사용할까?

저의 경우 FSD(Feature-Sliced Design) 아키텍처를 채택하여 구조를 설계했기 때문에, 여러 곳에서 활용되는 텍스트박스와 관련된 작은 UI들이 이미 존재하는 상황이었습니다. 이러한 UI들을 React Hook Form과 함께 사용하려면 Props를 수정해야 하는 걱정이 있었지만, React Hook Form은 이러한 부분까지 미리 고려하여 외부 UI 라이브러리와 간편하게 동기화할 수 있도록 Controller라는 래퍼를 제공 하고 있었습니다.

Controller

React Hook Form은 비제어 컴포넌트와 기본 입력 요소를 지원하지만, React-Select, AntD, MUI와 같은 외부 제어 컴포넌트와 작업하는 것은 피하기 어렵습니다. 이 래퍼 컴포넌트는 이러한 컴포넌트와 작업하는 것을 더 쉽게 만들어줍니다.

  • name : 입력 필드의 이름을 정의
  • control : useForm훅에서 반환된 control 객체를 전달
  • defaultValue : 초깃값
  • render : 외부 컴포넌트와 렌더링 하는 함수
  • rules : 여러 유효성 검사 규칙을 적용
rules={{
  required: "이 필드는 필수입니다.",
  minLength: { value: 5, message: "최소 5자 이상 입력해야 합니다." }
}}
  • filed : {onChange, onBlur, value, ref}
        <Controller
          name="content"
          control={control}
          render={({ field: { onChange } }) => (
            <MarkdownEditor
              onChange={onChange}
              placeholder="질문을 작성해주세요."
            />
          )}
        />

MarkdownEditor는 프로젝트 내에서 사용되는 react-markdown-editor-lite를 기반으로 만든 커스텀 에디터 컴포넌트입니다. 하지만 아쉽게도 MdEditor는 ref 속성을 제공하지 않아서 DOM에 직접 접근할 수 없는 상황이었지만, 다행히 Controller를 활용해 간단하게 동기화 문제를 해결할 수 있었습니다.

비제어 컴포넌트 인데 현재 상태 추적은 어떻게??

이전에 제어 컴포넌트와 비제어 컴포넌트 사이에서 고민할 때, 사용자의 입력에 따른 글자 수를 반영해야 하는 부분이 제어 컴포넌트를 선택하는 중요한 이유 중 하나였습니다. 그러나 React Hook Form은 이러한 문제들까지 간편하게 해결할 수 있도록 지원해주었습니다.

useWatch

useWatch는 React Hook Form의 훅으로, 특정 필드 또는 전체 폼의 입력 값을 관찰하기 위해 사용됩니다.

  • control : useForm에서 반환된 control 객체를 전달해야 합니다. 이 객체를 통해 useWatch가 폼의 상태를 관찰할 수 있습니다.
  • name : 관찰할 필드의 이름을 지정합니다.
  • defaultValue (선택사항) : 관찰할 필드의 기본값을 설정할 수 있습니다.
  • multiple (선택사항) : 배열 형태의 필드를 관찰하려는 경우, multiple: true 설정
  const watchedValue = useWatch({
    control,
    name: `qnaList.${index}.answer`,
    defaultValue: '',
  });

watch와 useWatch 차이 ?

useForm에는 폼 상태를 관찰하는 기능인 watch가 있기 때문에 useWatch에 대한 의문이 생길 수 있습니다. 주요 차이점은 관찰 대상의 범위입니다. watch는 폼의 전체 필드를 감시하기 때문에, 하나의 필드가 변해도 폼 전체가 리렌더링됩니다. 반면, useWatch는 특정 필드나 특정 그룹의 필드만을 관찰하므로 해당 필드를 포함한 컴포넌트 내부에서만 리렌더링이 발생하기 때문에 성능상 이점이 있습니다.

실제 사용 비교

watchuseWatch를 사용한 실제 예제를 비교해 보겠습니다.

watch방식

        <CoverLetterItem
          watchLen={watch(`qnaList.${index}.answer`).length}
          key={field.id}
         />

watch를 사용하여 특정 필드(qnaList.${index}.answer)의 상태를 추적하고 있음에도 불구하고, question 컴포넌트의 값을 변경할 때는 추적 대상이 아니기 때문에 렌더링이 발생하지 않습니다. 하지만, answer의 값을 변경하면 watch를 사용하여 전체 컴포넌트가 리렌더링되는 것을 확인할 수 있습니다.


useWatch를 활용한 방식의 예제를 살펴보겠습니다.

useWatch방식

반면, 아이템 컴포넌트 내부에서

 const watchedValue = useWatch({
    control,
    name: `qnaList.${index}.answer`,
    defaultValue: '',
  });

useWatch를 사용하여 해당 필드를 추적하는 경우, 해당 컴포넌트 내에서만 리렌더링이 발생하는 것을 확인할 수 있습니다.

전환 후 성능 비교

코드의 가독성은 확실히 개선되었지만, 입력 시마다 매번 리렌더링이 발생한다면 굳이 React Hook Form으로 전환하는 것이 큰 의미가 있는지에 대한 의문을 가지게되어 직접 성능을 비교 해보았습니다. 물론, 이전에 작성한 로직이 복잡한 부분을 감안하고 동일한 사람이 동일한 환경에서 같은 기능을 수행하는 로직을 작성했다는 상황에서 바라봐주셨으면 합니다.

React Hook Form 성능

제어 컴포넌트 성능

선행되는 그래프가 Hook Form을 사용한 경우, 후행되는 그래프는 제어 컴포넌트를 활용한 방식입니다. 선뜻 그래프를 바라보면 큰 차이를 못느끼지 못할 수도 있지만,그래프의 선택된 크기에 기준으로 바라보면 매번 입력 시 React Hook Form의 경우 평균 50kb의 메모리가 할당되는 반면, 제어컴포넌트는 150kb의 메모리 할당이 발생 했습니다. 이 부분은 제가 로직을 잘못 작성했다고 해도, 3배의 차이는 확실히 효율적이라는 것을 증명합니다.

모달이나 토스트 같은곳에 에러처리는 어떻게??

React Hook Form을 사용하면 폼 필드의 유효성 검사를 쉽게 관리할 수 있으며, 발생한 오류는 formState: { errors } 객체를 통해 간편하게 처리할 수 있습니다. 이 오류 정보를 바탕으로 모달이나 토스트를 통해 사용자에게 직관적으로 오류 메시지를 전달할 수 있습니다.

errors 객체 구조

  • ref : 해당 필드의 참조(DOM 참조)
  • message : 유효성 검사 실패 시 설정된 오류 메시지
  • type : 발생한 오류의 타입 (required, minLength)
필드: {
- ref : 
- message : 
- type :
}

사용법 예시

오류가 발생한 경우 errors 객체를 사용하여 간단하게 오류 메시지를 출력할 수 있습니다.

            <ERROR>
              {errors.answer.message || ''}
            </ERROR>

handleSubmit함수를 사용하면, 폼에서 오류가 발생할 때 onSubmit함수가 호출되지 않고, 대신 onError콜백에서 오류를 처리할 수 있습니다. 이를 통해 더욱 쉽게 공통적인 핸들링을 처리 할 수 있습니다.

handleSubmit(onSubmit, onError)

React Form 포커싱 처리

React 폼에서 여러 개의 입력 필드를 관리하고, 특정 유효성 검사 후 해당 필드로 포커스를 이동시키는 작업은 꽤나 번거롭고 복잡한 과정입니다. 저의 경우, 각 입력 필드에 대해 ref를 생성하고, 비어 있는 필드를 찾아 해당 ref로 포커스를 이동시키는 로직을 최대한 선언형에 가깝게 구성했습니다. 아래의 예제를 통해 제어 컴포넌트 방식에 처리를 통한 로직와 React-Hook-Form을 사용시의 차이를 설명 드리겠습니다.

1. Ref를 사용해 입력 필드 관리하기

우선, 각 입력 필드의 ref를 일관성 있게 처리하고 등록하기 위해 refs라는 객체를 생성했습니다.

  const refs: { [key: string]: React.RefObject<HTMLInputElement> } = {
    loginIdRef: useRef<HTMLInputElement>(null),
    passwordRef: useRef<HTMLInputElement>(null),
    confirmPasswordRef: useRef<HTMLInputElement>(null),
    emailRef: useRef<HTMLInputElement>(null),
    nickNameRef: useRef<HTMLInputElement>(null),
  };

2. 관리 대상의 필드가 비어 있는지 확인 후 포커스 이동

각 필드가 비어 있는지 확인하고, 비어 있을 경우 해당 ref로 포커스를 이동시키는 함수를 정의했습니다. 이 함수는 hasEmpty.fieldKey() 유틸리티를 사용해 필드의 값을 검사하고, 비어 있을 경우 해당 필드로 포커스를 맞추며, 검사 결과를 반환합니다.

  /** 관리 대상 ref가 비어있는지 체크 */
  const runEmptyChecks = () => {
    const emptyfield = hasEmpty.fieldKey(joinInfo);
    if (emptyfield) {
      refs[`${emptyfield}Ref`].current?.focus();
      return false;
    }
    return true;
  };

3. form 입력처리에 대한 유효성 검증

각 필드에 대한 유효성 검사 규칙을 정의하고, 검사를 통과하지 못한 경우 오류 메시지를 출력하도록 설정했습니다. validation 배열에는 검사 조건(check)과 오류 메시지(message)가 포함되어 있습니다.

  /** 회원가입시 룰을 정의 */
  const validation = [
    {
      check: () => !runEmptyChecks(),
      message: ERROR_MESSAGES.FILL_BLANKS,
    },
    {
      check: () => !isValid,
      message: errorMessage,
    },
    {
      check: () => joinInfo.password !== joinInfo.confirmPassword,
      message: ERROR_MESSAGES.CHECK_PASSWORD,
    },
  ];

4. 모든 유효성 검사 실행하기

유효성 검사를 순차적으로 실행하고, 오류가 발생할 경우 해당 메시지를 설정하고, 폼 제출을 중지합니다.

  /** 회원가입 시 전체 룰을 검사하고 에러메시지를 반영 */
  const runValidationChecks = () => {
    const isValidation = validation.find((item) => item.check());
    if (isValidation) {
      setUserMsg(isValidation.message);
      return false;
    }
    return true;
  };

5. onSubmit에 적용하기

  if (!runValidationChecks()) return;

이제, 1~4번에서 설명한 모든 과정을 onSubmit 이벤트 내에서 단 한 줄로 처리할 수 있습니다. runValidationChecks() 함수를 호출하여 모든 유효성 검사를 실행하고, 오류가 있을 경우 폼 제출을 중단하고 해당 필드로 포커스를 자동으로 이동시킬 수 있습니다.

React Hook Form 포커싱 지원

하지만 React Hook Form을 사용하면 이러한 복잡한 과정을 매우 간단하게 처리할 수 있습니다. 사실, 저 같은 경우는 이 기능을 접하고 나서 약간의 현타가 왔습니다. 왜 이렇게 간단하고 효율적인 방법을 놔두고, 복잡한 로직을 고민했는지 되돌아보게 되었죠.

React Hook Form은 별다른 설정 없이도, 오류가 발생한 필드에 자동으로 포커스를 맞추는 기능을 제공합니다. 우리가 할 일은 그저 이 기능을 필요에 따라 켜거나 끄는 것뿐입니다.

예를 들어, useForm 훅에서 shouldFocusError 옵션을 true로 설정하면 오류가 발생할 때 해당 필드에 자동으로 포커스를 이동시킬 수 있습니다.

useForm({
  criteriaMode: 'all',
  shouldFocusError: true, // 오류 발생 시 자동 포커싱
});

이 설정을 통해 폼의 유효성 검사에서 오류가 발생하면, 해당 필드로 포커스가 자동으로 이동하며, 별도의 복잡한 로직을 작성할 필요가 없습니다.

느낀 errors에 단점

각각의 필드에 대한 오류 처리는 매우 간편하지만, 동적 폼을 사용하는 경우 같은 형식의 필드 리스트에서 여러 개의 오류 메시지가 한번에 출력되면 UI가 지저분하게 느껴졌습니다. 특히, 중복된 오류 메시지가 많다면 사용자 경험이 더 나빠질 수 있다는 생각이 들었습니다.

그래서 저는 각 필드에서 발생하는 첫 번째 오류 메시지만 모달을 통해 사용자에게 전달하는 방식으로 처리하고 싶었지만 아쉽게도 React Hook Form에서는 이런 기능을 기본적으로 제공하지 않았습니다.

재귀적으로 errors를 처리

errors 객체는 항상 message라는 속성을 가지기 때문에, 첫 번째 오류 메시지를 재귀적으로 탐색하여 출력하는 것은 생각보다 간단했습니다. 비효율적인 방법일지는 모르겠지만, 현재로서는 제가 떠올릴 수 있는 최선의 방법이었습니다. 어쩌면 React Hook Form내에는 더 간편한 해결책이 있을지 모르겠지만, 제가 찾아본 결과로는 찾지 못했습니다. 혹시 이 글을 읽고 알고 계신 분이 있다면 공유해주시면 감사하겠습니다.

또한, 저는 Airbnb 스타일의 lint 규칙을 따르고 있기 때문에, for...of 루프, 제너레이터, 이터레이터의 사용이 제한되어 있었습니다. 그 결과, 아래와 같이 배열 메서드를 사용하여 오류 객체를 순회하는 코드를 구성하게 되었습니다.

// 에러 객체 순회 함수 (재귀적 탐색)
export const getFirstErrorMessage = (
  errorObj: FieldErrors<FormData> | undefined,
): string | null => {
  if (!errorObj) return null;

  return Object.values(errorObj).reduce<string | null>((acc, value) => {
    if (acc) return acc;

    if (value && typeof value === 'object' && 'message' in value) {
      return (value as { message: string }).message;
    }

    if (typeof value === 'object' && value !== null) {
      return getFirstErrorMessage(value as FieldErrors<FormData>);
    }

    return null;
  }, null);
};

Zod란 무엇인가?

이전에 잠깐 언급했던 Zod에 대해 더 깊이 알아보겠습니다. 최근 저는 FSD(Feature-Sliced Design) 아키텍처와 관련된 공식 자료를 탐구하는 과정에서 Zod를 자주 접하게 되었습니다. 처음에는 React Hook Form과 함께 쓰이는 라이브러리로만 생각했지만, 이후에도 다양한 프로젝트에서 Zod를 사용하는 사례를 보며, Zod가 단순히 React Hook Form을 위한 도구가 아니라는 것을 알게되었습니다.

Zod에 대하여

Zod는 다양한 환경에서 스키마를 정의하고 유효성을 검사할 수 있는 독립적인 데이터 검증 라이브러리입니다.

  • Zod는 React Hook Form과 결합하여 스키마 기반의 폼 유효성 검사를 처리할 수 있습니다.
  • 그 외 서버 요청이나 API 응답 데이터의 유효성 검증 등 다양한 검증 작업에 사용될 수 있습니다.
  • Zod는 TypeScript 우선으로 설계되어 강력한 타입 안전성을 제공합니다

Zod와 스키마

스키마란 무엇일까요? 스키마는 데이터의 구조와 형식을 정의하는 규칙이나 설계도와 같은 개념입니다. 예를 들어, 특정 데이터가 문자열이어야 한다거나 객체 안에 특정 속성이 있어야 한다는 규칙을 지정하는 것입니다.

Zod는 이런 스키마를 정의하는 작업을 아주 직관적이고 쉽게 만들어줍니다. 간단한 문자열부터 시작해서 중첩된 객체 구조까지 다양한 데이터 타입을 다루는 스키마를 생성할 수 있습니다.

사용 예제

다음은 Zod를 이용해 간단한 스키마를 정의하, 데이터를 검증하는 예시입니다.

import { z } from "zod";

// 문자열 스키마 정의
const nameSchema = z.string();

// 객체 스키마 정의
const userSchema = z.object({
  name: z.string(),
  age: z.number(),
});

// 유효성 검사
const result = userSchema.safeParse({
  name: "Test",
  age: 30,
});

if (result.success) {
  console.log("유효한 데이터입니다:", result.data);
} else {
  console.error("유효성 검사 실패:", result.error);
}

safeParse 메서드를 통해 데이터가 유효한지 여부를 확인하고, 오류가 발생하면 손쉽게 처리할 수 도 있습니다.

TypeScirpt와 차별점은 무엇인가?

많은 분들이 TypeScript와 Zod의 차이점에 대해 궁금해할 수 있습니다. TypeScript는 타입을 정의하고 컴파일 타임에 오류를 잡아주는 역할을 합니다. 반면에 Zod런타임에서 데이터를 검증합니다. 즉 TypeScript가 코드 작성 시점에 오류를 막아준다면, ZOd는 프로그램이 실행되는 동안 사용자의 입력이나 외부에서 들어오는 데이터를 검증함으로써 더 안전하게 데이터를 처리하도록 도와줍니다.

Zod와 React Hook Form

React Hook FormZod를 함께 사용하면 폼 유효성 검사를 더욱 효율적으로 처리할 수 있습니다. React Hook Form은 간단한 폼 상태 관리와 함께 빠른 성능을 제공하며, Zod는데이터 검증을 위한 강력한 스키마를 추가해줍니다. 이 조합을 사용하면 폼 입력에 대해 정교한 유효성 검사를 쉽게 구현할 수 있습니다.

1. 스키마 정의하기

아래와 같이 Zod 스키마를 정의하면 데이터가 더 직관적으로 표현될 수 있고, 의도를 명확하게 파악할 수 있습니다.

import { z } from 'zod';

export const schema = z.object({
  title: z.string().trim().min(1, '대표 제목을 설정해주세요.'),
  qnaList: z.array(
    z.object({
      question: z.string().trim().min(1, '빈값을 채워주세요.'),
      answer: z
        .string()
        .min(30, { message: '질문은 30자이상 입력해주세요.' }),
    }),
  ).min(1, '최소 한개의 문항은 작성해주세요..'),
});

2. React Hook Form와 통합

useForm 훅을 사용할 때 resolver 옵션을 통해 Zod 스키마를 간편하게 통합할 수 있습니다.

  const {
    register,
    control,
    handleSubmit,
    reset,
    formState: { errors },
  } = useForm<coverLetterType.ICoverLetter>({
    resolver: zodResolver(coverLetterModel.schema),
    defaultValues: {
      title: '',
      qnaList: [],
    },
  });

위와 같이 resolver에 Zod 스키마를 연결하는 것만으로도 errors 객체를 통해 오류를 표시할 수 있습니다. 이렇게 하면 유효성 검사 로직과 폼 상태 관리 로직을 분리할 수 있어 코드가 더 깔끔하고 유지보수가 쉬워집니다.

Zod와 yup 차이점

Zod는 TypeScript와의 통합을 우선으로 설계되어 강력한 타입 안정성을 제공합니다. 반면, Yup은 TypeScript를 지원하지만 TypeScript-first가 아닌 만큼 통합의 수준이 Zod보다 낮습니다.

Zod는 기본적으로 엄격한(strict)검증을 적용합니다. Yup은 기본적으로 느슨한 검증을 사용하며, 이로 인해 잘못된 데이터 타입을 자동 변환하려는 시도가 발생할 수 있습니다.

  • Yup에서 strict()메서드를 명시적으로 사용해야한다.

Zod는 간단한 문법으로 복잡한 검증 로직을 쉽게 구현할 수 있습니다. Yup 역시 체이닝을 통한 검증 로직을 제공하지만, Zod의 직관적인 API 덕분에 더 빠르게 작성할 수 있는 경우가 많습니다.

-https://blog.logrocket.com/comparing-schema-validation-libraries-zod-vs-yup/

profile
성장은 점진적 과부하, 매주 회고를 목표로 시작했지만 그때 그때 컨셉이 달라요. 시행착오를 통해 저만의 방식을 찾아가는중입니다.

0개의 댓글