본문 바로가기

FrontEnd

React-Hook-Forms로 재사용 가능한 폼 만들기

반응형

원문 : https://www.thisdot.co/blog/how-to-create-reusable-form-components-with-react-hook-forms-and-typescript

 

How to create reusable form components with React Hook Forms and Typescript - This Dot Labs

Why Should I Create Reusable react-hook-form Input Components? Like many in the React community, you've decided to use react-hook-form. While not every…

www.thisdot.co

완성된 코드 보기

https://stackblitz.com/edit/reusable-rhf-ts-pt6?file=src%2Fcomponents%2Fatoms%2Finput.tsx 

 

Reusable React Hook Form Typescript Components - Live Example - StackBlitz

 

stackblitz.com

Step 1:Input 컴포넌트 만들기

첫 번째 단계는 Input Component를 만드는 것입니다.
 
격리된 컴포넌트는 유효성 검사 또는 리액트 훅에 직접 연결되지 않은 Input을 제공하는 좋은 방법이며,
또한 더 쉬운 단위 테스트를 위해 Input 논리를 분리하며 스타일을 통합하는데 좋은 방법일 수 있습니다.
이 컴포넌트를 사용하여 Input에 label, type 및 name이 있는지 확인하여 우수한 접근성 사례를 시행할 수 있습니다.
import React, {
  FC,
  forwardRef,
  DetailedHTMLProps,
  InputHTMLAttributes,
} from 'react';
import classNames from 'classnames';

export type InputSize = 'medium' | 'large';
export type InputType = 'text' | 'email';

export type InputProps = {
  id: string;
  name: string;
  label: string;
  type?: InputType;
  size?: InputSize;
  className?: string;
} & Omit<
  DetailedHTMLProps<InputHTMLAttributes<HTMLInputElement>, HTMLInputElement>,
  'size'
>;

// Using maps so that the full Tailwind classes can be seen for purging
// see https://tailwindcss.com/docs/optimizing-for-production#writing-purgeable-html

const sizeMap: { [key in InputSize]: string } = {
  medium: 'p-3 text-base',
  large: 'p-4 text-base',
};

export const Input: FC<InputProps> = forwardRef<HTMLInputElement, InputProps>(
  (
    {
      id,
      name,
      label,
      type = 'text',
      size = 'medium',
      className = '',
      placeholder,
      ...props
    },
    ref
  ) => {
    return (
      <input
        id={id}
        ref={ref}
        name={name}
        type={type}
        aria-label={label}
        placeholder={placeholder}
        className={classNames([
          'relative inline-flex w-full rounded leading-none transition-colors ease-in-out placeholder-gray-500 text-gray-700 bg-gray-50 border border-gray-300 hover:border-blue-400 focus:outline-none focus:border-blue-400 focus:ring-blue-400 focus:ring-4 focus:ring-opacity-30',
          sizeMap[size],
          className,
        ])}
        {...props}
      />
    );
  }
);​

https://stackblitz.com/edit/reusable-rhf-ts-pt6?file=src%2Fcomponents%2Fatoms%2Finput.tsx 

 

Reusable React Hook Form Typescript Components - Live Example - StackBlitz

 

stackblitz.com


Step 2: Creating a Form

등록 폼을 상상해 봅시다.
import React, { FC } from 'react';
import { Input } from '../atoms/input';

export const RegistrationForm: FC = () => {
  return (
    <form>
      <Input
        id="firstName"
        type="text"
        name="firstName"
        label="First Name"
        placeholder="First Name"
      />
    </form>
  );
};

firstName 필드를 필수로 만들고, 검증 로직을 추가하고 싶습니다.

그리고 양식을 제출하는 방법을 추가해야 합니다.

 

<RegistrationForm>에서 useForm 호출에서 handleSubmit 함수를 구조 분해로 꺼내온 다음

이를 사용하여 양식 제출을 위해 호출할 onSubmit 함수를 생성합니다.

https://stackblitz.com/edit/reusable-rhf-ts-pt6?file=src%2Fcomponents%2Forganisms%2Fregistration-form.tsx 

 

Reusable React Hook Form Typescript Components - Live Example - StackBlitz

 

stackblitz.com

import React, { FC } from 'react';
import { useForm } from 'react-hook-form';
import { Input } from '../atoms/input';

export type RegistrationFormFields = {
  firstName: string;
};

export const RegistrationForm: FC = () => {
  const {
    register,
    handleSubmit,
    formState: { errors },
  } = useForm<RegistrationFormFields>();

  const onSubmit = handleSubmit((data) => {
    console.log('submitting...');
  });

  return (
    <form onSubmit={onSubmit}>
      <Input
        id="firstName"
        type="text"
        name="firstName"
        label="First Name"
        placeholder="First Name"
      />
      <button
        className="mt-4 transform duration-200 py-2 px-4 bg-blue-500 text-white font-semibold rounded shadow-md hover:bg-blue-600 focus:outline-none disabled:opacity-50 focus:translate-y-1 hover:-translate-y-1"
        type="submit"
      >
        Submit
      </button>
    </form>
  );
};

Step 3: Create a Validated Input Component

<Input> 컴포넌트와와 react-hook-form을 모두 사용하여
유효성 검사 규칙과 잠재적 오류를 전달할 수 있는 재사용 가능한 컴포넌트를 만드는 래퍼 컴포넌트를 만들고 싶습니다.
<FormInput>이라는 새로운 컴포넌트를 만들어 봅시다.
import React from 'react';
import { Input, InputProps } from '../atoms/input';

export type FormInputProps = InputProps;

export const FormInput = ({
  className,
  ...props
}: FormInputProps): JSX.Element => {
  return (
    <div className={className} aria-live="polite">
      <Input {...props} />
    </div>
  );
};

https://stackblitz.com/edit/reusable-rhf-ts-pt6?file=src%2Fcomponents%2Fmolecules%2Fform-input.tsx 

 

Reusable React Hook Form Typescript Components - Live Example - StackBlitz

 

stackblitz.com

react-hook-form에 Input 등록하기

이제 react-hook-form에 Input을 등록해야 합니다.
첫 번째 단계는 react-hook-form이 제공한 register 함수 프로퍼티를 일반 <FormInput> 컴포넌트에 전달하는 것입니다.
해당 프로퍼티의 타입은 무엇인가요?
import React from 'react';
import { InputProps } from '../atoms/input';

export type FormInputProps = {
  register: ???; // 뭘까요?
} & InputProps;
직접 타입을 체크해 봅시다.
레지스터 타입이 UserFormRegister<RegistrationFormFields>이지만
UserFormRegister<RegistrationFormFields> 타입의 register 속성을 전달하면
<FormInput> 컴포넌트를 제네릭한 상태로 유지하는 데 도움이 되지 않습니다.
 
모든 폼에 우리의 등록 폼에 속하는 입력이 있는 것은 아닙니다.
각 양식에는 다른 유효성 검사 규칙과 오류가 있을 수 있습니다.
모든 타입의 폼을 취할 수 있는 register 속성을 가지려면 <FormInput>이 필요합니다.
<FormInput> 속성의 타입을 변경해 보겠습니다.
import React from 'react';
import { InputProps } from '../atoms/input';

export type FormInputProps<TFormValues> = {
  register: ???;
} & InputProps;
<TFormValues>는 모든 잠재적인 폼을 나타내는 제네릭 타입입니다.
우리가 만들 폼 타입은 RegistrationFormFields이지만
<TFormValues>를 사용한다는 것은 이 컴포넌트를 모든 폼에서 사용할 수 있음을 의미합니다.
 
다음으로 레지스터 속성을 Input에 전달하는 방법을 살펴보겠습니다.
import React from 'react';
import { UseFormRegister } from 'react-hook-form';
import { InputProps } from '../atoms/input';

export type FormInputProps<TFormValues> = {
  register?: UseFormRegister<TFormValues>;
};
 
이제 react-hook-form에 모든 타입의 폼에서 사용되는 Input을 등록할 수 있습니다.
그러나 아직 끝나지 않았습니다.
우리는 여전히 우리가 등록하려는 양식의 input/field를 react-hook-form에 알려야 합니다.
register 함수에 전달할 name 속성을 사용하여 이 작업을 수행합니다.
 
 

name Property 정의하기

자세히 살펴보면 register의 파라미터의 타입이 TFormValues의 필드 중 하나에 대한 path임을 알 수 있습니다.
// 아래 타입의 Path
export type RegistrationFormFields = {
  firstName: string;
  lastName: string;
  email: string;
  bio?: string;
};

 
이유는 폼 타입인 TFormValues에 개체 또는 배열과 같은 중첩 필드가 있을 수 있기 때문입니다.
예를 들어, 어떤 이유로 한 사람만 등록하는 대신 등록할 사람 목록이 있다고 상상해 보십시오.
RegistrationFormFields는 대신 다음과 같이 보일 수 있습니다.
export type RegistrationFormFields = {
  people: { firstName: string }[];
};


입력 필드의 name 속성이 문자열인 경우 양식에서 첫 번째 또는 두 번째 사람의 이름을 참조할 수 없습니다.
그러나 name이 Path 유형이면 중첩된 개체 또는 배열의 필드에 액세스할 수 있습니다.
예를 들어 이름은 people[0].firstName 또는 people.[1].firstName일 수 있습니다.
 
<FormInput> 컴포넌트의 속성은 이제 다음과 같아야 합니다.
import React from 'react';
import { UseFormRegister, Path } from 'react-hook-form';
import { InputProps } from '../atoms/input';

export type FormInputProps<TFormValues> = {
  name: Path<TFormValues>;
  register?: UseFormRegister<TFormValues>;
} & InputProps;

기다리세요! 이제 InputProps와 FormInputProps 모두에 name 필드가 있습니다.
이 이름들은 같은 것이 아닙니다!
InputProps의 name은 HTML Input 요소의 기본 name을 나타냅니다.
FormInputProps의 name은 Form의 필드에 대한 경로입니다.
 
우리는 여전히 다른 기본 HTML 속성을 입력에 전달할 수 있기를 원합니다.
name을 FormInputProps의 path로 정의하고 있기 때문에 InputProps에서 name을 제외합니다.
import React from 'react';
import { UseFormRegister, Path } from 'react-hook-form';
import { InputProps } from '../atoms/input';

export type FormInputProps<TFormValues> = {
  name: Path<TFormValues>;
  register?: UseFormRegister<TFormValues>;
} & Omit<InputProps, 'name'>;

 
이제 마지막 유효성 검사 규칙이 남았습니다.
유효성 검사 규칙을 <FormInput>에 전달한 다음 register 함수에 전달합니다.

Rules Property 정의하기

우리는 등록 폼에 적용되는 규칙뿐만 아니라 모든 규칙을 <FormInput> 컴포넌트에 전달할 수 있기를 원한다는 것을 기억하십시오.

고맙게도 이것은 조금 더 쉽습니다.

import React from 'react';
import { UseFormRegister, Path, RegisterOptions } from 'react-hook-form';
import { InputProps } from '../atoms/input';

export type FormInputProps<TFormValues> = {
  name: Path<TFormValues>;
  rules?: RegisterOptions; // here!
  register?: UseFormRegister<TFormValues>;
} & Omit<InputProps, 'name'>;

이러한 속성을 사용하여 실제로 등록하고 규칙을 적용하고 <FormInput> 컴포넌트를 렌더링해 보겠습니다.
또한 사용하던 <Input> 컴포넌트를 <RegistrationForm>의 새 <FormInput> 구성 요소로 교체하려고 합니다.
다음과 같이 표시되어야 합니다.

https://stackblitz.com/edit/reusable-rhf-ts-pt6?file=src%2Fcomponents%2Forganisms%2Fregistration-form.tsx,src%2Fcomponents%2Fmolecules%2Fform-input.tsx 

 

Reusable React Hook Form Typescript Components - Live Example - StackBlitz

 

stackblitz.com

name을 입력하지 않아도 오류가 표시되지 않습니다. 이를 수정합시다.

Errors Property 정의

Error property를 정의할 때 오류에 어떤 타입을 할당해야 하나요?

react-hook-form이 말하는 Error type이 무엇인지 살펴보겠습니다.

Error 타입은 다음과 같습니다.
{
  firstName?: FieldError;
}
이전과 마찬가지로 { firstName?: FieldError; 타입의 오류 속성을 전달합니다. }는
<FormInput> 컴포넌트를 제네릭하게 유지하는 데 도움이 되지 않습니다.
모든 양식에 등록 폼의 오류가 있는 것은 아닙니다.
각 폼 별로 각각의 오류 타입이 있을 수 있습니다.
<TFormValues> 제네릭을 다시 사용해야 합니다.
import React from 'react';
import {
  RegisterOptions,
  DeepMap,
  FieldError,
  UseFormRegister,
  Path,
} from 'react-hook-form';
import { InputProps } from '../atoms/input';

export type FormInputProps<TFormValues> = {
  name: Path<TFormValues>;
  rules?: RegisterOptions;
  register?: UseFormRegister<TFormValues>;
  errors?: DeepMap<TFormValues, FieldError>;
} & Omit<InputProps, 'name'>;
 

오류 타입이 DeepMap<TFormValues, FieldError>여야 한다는 결론을 내리는 방법이 궁금할 것입니다. 
react-hook-form의 TS 문서에서 FieldErrors를 찾아 타입을 찾을 수 있습니다.
 
<RegistrationForm>의 react-hook-form에서 발생하는 오류를 <FormInput>의 errors 속성으로 전달해 보겠습니다.

useForm에서 전달하는 오류 타입이 <FormInput>에서 예상하는 오류 타입과 일치하지 않는 것 같습니다.
useForm에서 전달하는 오류 타입에는 다음과 같은 타입이 있습니다.
UseForm Errors Type
 
<FormInput>에서 예상하는 오류 타입은 다음과 같습니다.
...
export type FormInputProps<TFormValues> = {
  ...
  errors?: DeepMap<TFormValues, FieldError>;
} & Omit<InputProps, 'name'>;​

 
<FormInput>의 오류 속성이 DeepMap<TFormValues, FieldError> 타입인 것 같습니다.
 
이것은 정확하지 않습니다. 폼의 모든 단일 필드에 항상 오류가 있는 것은 아닙니다.
사용자가 데이터를 입력하면 오류가 동적으로 변경됩니다.
firstName 필드에 오류가 있을 수 있지만 firstName 필드가 유효하면 오류가 발생하지 않습니다.
따라서 RegistrationFormFields에는 firstName이 선택 사항이 아니라고 명시되어 있지만
오류는 모든 firstName 오류가 선택 사항임을 선언해야 합니다.
<FormInput>의 오류 속성을 <Partial>로 만들어 이를 달성할 수 있습니다.
...
export type FormInputProps<TFormValues> = {
  ...
  errors?: Partial<DeepMap<TFormValues, FieldError>>;
} & Omit<InputProps, 'name'>;​

이것은 오류가 TFormValues의 모든 필드를 포함할 필요가 없다는 것을 의미하며,

Typescript 오류는 이제 사라졌습니다!


에러 표시하기

오류 스타일이 Input 전체에서 동일하게 하기 위해 <FormInput> 컴포넌트에 이러한 오류를 표시하는 코드를 넣을 것입니다.
오류에는 한 필드의 오류뿐만 아니라 Form에 대한 모든 오류가 포함됩니다.
오류를 렌더링하기 위해 이 특정 입력과 관련된 오류를 얻으려고 합니다.
첫 번째 본능은 다음과 같이 Input의 name으로 오류를 얻는 것일 수 있습니다.
export const FormInput = <TFormValues extends Record<string, unknown>>({
  name,
  register,
  rules,
  errors,
  className,
  ...props
}: FormInputProps<TFormValues>): JSX.Element => {
  const errorMessages = errors[name];
  const hasError = !!(errors && errorMessages);

  return (
    <div className={className} aria-live="polite">
      <Input name={name} {...props} {...(register && register(name, rules))} />
      {hasError && <p>{errorMessages}</p>}
    </div>
  );
};

그러나 이름은 경로입니다. 중첩된 개체 또는 배열의 필드를 참조할 수 있습니다.
예를 들어, errors['people[0].firstName']가 아니라 errors.people[0].firstName을 가져와야 합니다.
이를 위해 lodash.get을 사용하여 Path를 통해 오류 값을 검색할 수 있습니다.

export const FormInput = <TFormValues extends Record<string, unknown>>({
  name,
  register,
  rules,
  errors,
  className,
  ...props
}: FormInputProps<TFormValues>): JSX.Element => {
  // If the name is in a FieldArray, it will be 'fields.index.fieldName' and errors[name] won't return anything, so we are using lodash get
  const errorMessages = get(errors, name);
  const hasError = !!(errors && errorMessages);

  return (
    <div className={className} aria-live="polite">
      <Input name={name} {...props} {...(register && register(name, rules))} />
      {hasError && <p>{errorMessages}</p>}
    </div>
  );
};​

하지만 여전히 뭔가 부족합니다.
현재 errorMessage는 각 입력에 대해 위반된 각 규칙을 나타내는 객체입니다.
예를 들어 firstName 필드 외에 email 필드가 있다고 가정해 보겠습니다.
이 이메일 필드는 필수일 뿐만 아니라 최소 4자를 입력해야 하며 유효한 이메일 형식과 일치해야 합니다.
입력한 이메일이 너무 짧고 이메일 형식과 일치하지 않는 경우와 같이 둘 이상의 규칙을 위반한 경우
errorMessage에는 pattern 및 minLength라는 여러 키가 있습니다.
 
패턴은 이메일 타입이 유효하지 않음을 나타냅니다.
minLength는 입력한 이메일이 충분히 길지 않음을 나타냅니다.
대부분의 경우 한번에 오류메세지를 표시하려 합니다.
제네릭한 Input 컴포넌트에 표시할 오류 메시지를 어떻게 알 수 있습니까?
 
모든 폼의 타입은 완전히 다르게 구성됩니다.
우리는 오류의 형태가 무엇인지 모릅니다.
타입이 아무 것도 아닌 것처럼 보일 때 특정 오류 메시지를 어떻게 표시할 수 있습니까?
 
다행히도 이 작업을 수행할 수 있는 컴포넌트가 포함된 사용할 수 있는 또 다른 패키지가 있습니다.
해당 구성 요소를 ErrorMessage라고 하며 @hookform/error-message에서 제공합니다.
yarn 또는 npm을 사용하여 이 패키지를 설치합니다.
npm install @hookform/error-message 또는 yarn add @hookform/error-message
해당 패키지가 추가되면 <FormInput>에서 사용할 수 있습니다.
 
import React from 'react';
import classNames from 'classnames';
import get from 'lodash.get';

import {
  RegisterOptions,
  DeepMap,
  FieldError,
  UseFormRegister,
  Path,
} from 'react-hook-form';
import { ErrorMessage } from '@hookform/error-message';
import { Input, InputProps } from '../atoms/input';

export type FormInputProps<TFormValues> = {
  name: Path<TFormValues>;
  rules?: RegisterOptions;
  register?: UseFormRegister<TFormValues>;
  errors?: Partial<DeepMap<TFormValues, FieldError>>;
} & Omit<InputProps, 'name'>;

export const FormInput = <TFormValues extends Record<string, unknown>>({
  name,
  register,
  rules,
  errors,
  className,
  ...props
}: FormInputProps<TFormValues>): JSX.Element => {
  // If the name is in a FieldArray, it will be 'fields.index.fieldName' and errors[name] won't return anything, so we are using lodash get
  const errorMessages = get(errors, name);
  const hasError = !!(errors && errorMessages);

  return (
    <div className={className} aria-live="polite">
      <Input
        name={name}
        aria-invalid={hasError}
        className={classNames({
          'transition-colors focus:outline-none focus:ring-2 focus:ring-opacity-50 border-red-600 hover:border-red-600 focus:border-red-600 focus:ring-red-600':
            hasError,
        })}
        {...props}
        {...(register && register(name, rules))}
      />
      <ErrorMessage
        errors={errors}
        // eslint-disable-next-line @typescript-eslint/no-explicit-any
        name={name as any}
        render={({ message }) => (
          <p className="mt-1 font-serif text-sm text-left block text-red-600">
            {message}
          </p>
        )}
      />
    </div>
  );
};
<ErrorMessage> 컴포넌트는 오류 property를 사용합니다.
이것은 <FormInput>에 전달한 오류와 동일하며 Form의 모든 필드에 대한 모든 오류를 나타냅니다.
<ErrorMessage>에 전달하는 두 번째 속성은 name 입니다.
<ErrorMessage>의 name 타입이 <FormInput>으로 가져오는 name 속성과 일치하지 않습니다.
 
name은 <ErrorMessage> 컴포넌트의 FieldName<FieldValuesFromFieldErrors<DeepMap<TFormValues, FieldError>>>
타입이지만
 
register 함수 호출에서는 두 name 속성이 모두 나타나는 경우에도 Path<TFormValues> 타입입니다.
즉, 양식에 있는 필드의 경로입니다.
 
FieldValuesFromFieldErrors 타입이 export 되지 않았기 때문에
name을FieldName<FieldValuesFromFieldErrors<DeepMap<TFormValues, FieldError>>>로 캐스팅할 수 없었으므로
any로 캐스팅해야 했습니다.
<ErrorMessage>에 전달하는 마지막 속성은 렌더링 함수입니다.
이 렌더링 함수를 사용하면 표시하려는 단일 오류 메시지에 액세스할 수 있습니다.
이 함수를 사용하여 원하는 방식으로 메시지를 표시할 수 있습니다.

결론

 
이제 React-hook-forms에서 유효성 검사 논리를 추출하여 Typescript에서 재사용 가능한 컴포넌트를 만드는 방법에 대해 조금 더 알게 되었습니다.
이 패턴을 계속 사용하여 확인란, 전화 번호 입력, 선택 상자 등과 같은 더 많은 폼 컴포넌트를 만들 수 있습니다.

완성 예제 보기 : https://stackblitz.com/edit/reusable-rhf-ts-pt6?file=src%2Fcomponents%2Forganisms%2Fregistration-form.tsx,src%2Fcomponents%2Fmolecules%2Fform-input.tsx 

 

Reusable React Hook Form Typescript Components - Live Example - StackBlitz

 

stackblitz.com

 
 

 

반응형