⚛️React | 리액트 훅 폼 : resister와 Controller (+ UseController)

dayannne·2024년 3월 2일
0

React

목록 보기
8/13
post-thumbnail

💡 resister와 Controller의 차이점


resister와 Controller의 주요 차이는 Uncontrolled Components와 Controlled Components의 사용과 관련이 있다.

- Uncontrolled Components와 Controlled Components

  • Uncontrolled Components(비제어 컴포넌트) : React 내 상태 관리가 아닌 DOM 자체로부터 데이터를 관리하는 컴포넌트
  • Controlled Components(비제어 컴포넌트) : React 상태에 따라 렌더링되는 컴포넌트

1) register 사용

register는 주로 Uncontrolled Components(비제어 컴포넌트)를 관리하기 위해 사용된다.

  • <input {...register('fieldName')}> 형태로 간단히 사용할 수 있다.
  • register를 사용한 필드는 사용자 입력에 의한 re-rendering이 발생하지 않아 불필요한 렌더링을 방지할 수 있다.

2) Controller 사용

Controller는 Controlled Components를 관리하기 위해 사용된다.

  • 특히 외부 UI 라이브러리 Mui(Material-UI) AntD 등에서 제공하는 컴포넌트가 Controlled Components로, React Hook Form을 연동할 때 자주 사용된다.
  • React Hook Form은 기본적으로 Uncontrolled Components를 권장하지만, Controlled Components에 리액트 훅 폼으로 상태를 관리할 필요가 있을 경우 Controller 컴포넌트 또는 useController 커스텀 훅을 사용한다.
  • Controller 컴포넌트의 경우 <Controller {...controlProps} /> 형태로 사용되며, React의 상태 기반으로 input을 제어하여 필요할 때마다 re-rendering을 수행한다.

Resister를 사용한 로그인 폼 만들기

import React from 'react';
import { useForm, SubmitHandler } from 'react-hook-form';
import { emailReg, passwordReg } from '@/util/Regex';

type FormValues = {
  email: string;
  password: string;
};

const Form = () => {
  const {
    register,
    handleSubmit,
    formState: { isValid, errors },
  } = useForm<FormValues>({
    defaultValues: {
      email: '',
      password: '',
    },
  });

  const onSubmit: SubmitHandler<FormValues> = (data) => {
    console.log(data);
  };

  return (
    <form onSubmit={handleSubmit(onSubmit)}>
      <label>
        이메일
        <input
          {...register('email', {
            required: '이메일을 입력해 주세요.',
            pattern: {
              value: emailReg,
              message: '올바른 이메일 형식을 입력해 주세요.',
            },
          })}
        />
        {errors.email && <span>{errors.email.message}</span>}
      </label>
      <label>
        비밀번호
        <input
          {...register('password', {
            required: '비밀번호를 입력해 주세요.',
            minLength: {
              value: passwordReg,
              message: '비밀번호는 최소 8자 이상이어야 합니다.',
            },
          })}
        />
        {errors.password && <span>{errors.password.message}</span>}
      </label>
      <button disabled={!isValid} type='submit'>
        제출
      </button>
    </form>
  );
};

export default Form;

register 함수를 사용하여 각 입력 필드를 form에 등록한 형태의 Form 컴포넌트이다.

  • register : register 함수는 첫 번째 인자로 field name을 받고, 두 번째 인자로 validation rules를 객체 형태로 받는다.

Controller를 사용한 로그인 폼 만들기

다음으로는 위 코드를 변경해 Controller를 사용하여 input, label, 에러메시지를 하나의 컴포넌트로 관리하고 제어할 수 있도록 만들어 보자.
각각 Controller 컴포넌트와 useController 커스텀 훅 버전으로 만들어 비교해 보았다.

1. Form 컴포넌트 생성

전체 코드

// Form.tsx
import React from 'react';
import { useForm, SubmitHandler } from 'react-hook-form';
import InputWithLabel from '@/components/common/InputWithLabel'; 
import { emailReg, passwordReg } from '@/util/Regex';

type FormValues = {
  email: string;
  password: string;
};

const Form = () => {
  const {
    control,
    handleSubmit,
    formState: { isValid },
  } = useForm<FormValues>({
    defaultValues: {
      email: '',
      password: '',
    },
  });

  const onSubmit: SubmitHandler<FormValues> = (data) => {
    console.log(data);
  };

  return (
    <form onSubmit={handleSubmit(onSubmit)}>
      <InputWithLabel
        control={control}
        name='email'
        label='이메일'
        rules={{
          required: '이메일을 입력해 주세요.',
          pattern: {
            value: emailReg,
            message: '올바른 이메일 형식을 입력해 주세요.',
          },
        }}
      />
      <InputWithLabel
        control={control}
        name='password'
        label='비밀번호'
        rules={{
          required: '비밀번호를 입력해 주세요.',
          minLength: {
            value: passwordReg,
            message: '비밀번호는 최소 8자 이상이어야 합니다.',
          },
        }}
        defaultValue=''
      />
      <button disabled={!isValid} type='submit'>
        제출
      </button>
    </form>
  );
};

export default Form;

- useForm 정의

  const {
    control,
    handleSubmit,
    formState: { isValid },
  } = useForm<FormValues>({
    defaultValues: {
      email: '',
      password: '',
    },
  });
  • useForm hook을 이용하여 form control을 설정하고, handleSubmit 함수와 formState를 가져온다. useForm의 인자로는 form의 defaultValues를 미리 설정했다.

- Controller/useController 훅으로 만들 Input 컴포넌트에 props 전달

return (
    <form onSubmit={handleSubmit(onSubmit)}>
      <InputWithLabel
        control={control}
        name='email'
        label='이메일'
        rules={{
          required: '이메일을 입력해 주세요.',
          pattern: {
            value: emailReg,
            message: '올바른 이메일 형식을 입력해 주세요.',
          },
        }}
      />
      <InputWithLabel
        control={control}
        name='password'
        label='비밀번호'
        rules={{
          required: '비밀번호를 입력해 주세요.',
          minLength: {
            value: passwordReg,
            message: '비밀번호는 최소 8자 이상이어야 합니다.',
          },
        }}
      />
      <button disabled={!isValid} type='submit'>
        제출
      </button>
    </form>
  );

InputWithLabel 컴포넌트는 이메일과 비밀번호 필드를 위해 각각 사용되며, 각 필드의 유효성 검사 규칙을 props로 전달한다.

  • control: useForm hook에서 반환된 control 객체로, form의 상태와 form field를 제어하는데 필요한 메서드들을 포함하고 있다.
  • name: form field의 이름
  • rules: form field의 유효성 검사 규칙을 설정하는 객체로, 여기서는 'required'와 'minLength' 규칙을 설정했다.

2. InputWithLabel (useController 훅.ver)

전체 코드

import { InputHTMLAttributes } from 'react';
import {
  useController,
  FieldValues,
  FieldPath,
  UseControllerProps,
} from 'react-hook-form';

interface TextInputProps<
  TFieldValues extends FieldValues = FieldValues,
  TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,
> extends UseControllerProps<TFieldValues, TName>,
    Omit<InputHTMLAttributes<HTMLInputElement>, 'name' | 'defaultValue'> {
  label: string;
}

export const InputWithLabel = ({
  control,
  name,
  rules,
  defaultValue,
  label,
  ...rest
}: TextInputProps) => {
  const {
    field: { ref, ...inputProps },
    fieldState: { error },
  } = useController({
    name,
    control,
    rules,
    defaultValue,
  });

  return (
    <label>
      {label}
      <input {...inputProps} {...rest} ref={ref} />
      {error && <span>{error.message}</span>}
    </label>
  );
};

1) Interface 정의

interface TextInputProps<
  TFieldValues extends FieldValues = FieldValues,
  TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,
> extends UseControllerProps<TFieldValues, TName>,
    Omit<InputHTMLAttributes<HTMLInputElement>, 'name' | 'defaultValue'> {
  label: string;
}

UseControllerPropsInputHTMLAttributes<HTMLInputElement>를 extend해 Props Interface를 생성했다. 이렇게 설정하면 form field를 제어하는 데 필요한 속성들과 입력 필드에 필요한 속성을 모두 가질 수 있게 된다. 해당 코드에서는 InputHTMLAttributes<HTMLInputElement>의 속성들 중 namedefaultValue를 제외하고 나머지 속성들을 사용하며, label과 같이 이외 임의로 지정하는 속성을 추가했다.

2) useController hook 설정

const {
  field: { ref, ...inputProps },
  fieldState: { error },
} = useController({
  name,
  control,
  rules,
  defaultValue,
});

useController를 사용해 각 필드의 DOM ref와 field의 inputProps(onChange, onBlur, value 등)를 반환받는다. control을 포함해 Form 컴포넌트에서 받아온 props들은 useController의 인자로 넣어준다.

3) Render

export const InputWithLabel = ({
  control,
  name,
  rules,
  defaultValue,
  label,
  ...rest
}: TextInputProps) => {
  const {
    field: { ref, ...inputProps },
    fieldState: { error },
  } = useController({
    name,
    control,
    rules,
    defaultValue,
  });

  return (
    <label>
      {label}
      <input {...inputProps} {...rest} ref={ref} />
      {error && <span>{error.message}</span>}
    </label>
  );
};

마지막으로 labelinput 요소, 그리고 error 메시지를 렌더링한다. input 요소는 inputPropsrest props를 받고, ref는 useController에서 반환된 ref를 사용한다.

⚠️ 주의할 점

⚠️ {...inputProps}(field의 기능들){...rest}(상위에서 전달 받은 props) - input 태그에 전달 시 두 props 전달의 순서가 코드의 영향을 미칠 수 있다?

<input {...rest} {...inputProps} ref={ref} />

최근 진행중인 실무 프로젝트에서 {...rest} {...inputProps} 순서와 같이 inputProps 순서를 뒤로 지정한 상태에서 겪은 문제이다.
상위에서 disabled라는 임의의 props를 만들어서 {...rest}로 가져와 넘겨 주었는데 이게 input에만 적용이 되지 않아 disabled는 자꾸 undefined가 담기는 것이었다. (해당 컴포넌트에서는 잘 찍히는 상태)
해결 방법은 간단하게도 {...inputProps} {...rest}와 같이 순서를 바꾸는 것이었다.
fieldinputProps 종류 중 내가 임의로 만들어 가져온 props와 이름이 동일한 속성(혹은 메서드와 같은 기능)이 존재할 경우 해당 fieldinputProps가 적용될 수 있으니 주의해 사용하면 좋을 것 같다.

3. InputWithLabel (Controller 컴포넌트.ver)

InputWithLabel을 Controller 컴포넌트로 만들면 다음과 같다.

import { InputHTMLAttributes } from 'react';
import { Controller, Control, FieldValues, FieldPath } from 'react-hook-form';

interface TextInputProps<
  TFieldValues extends FieldValues = FieldValues,
  TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,
> extends Omit<InputHTMLAttributes<HTMLInputElement>, 'name' | 'defaultValue'> {
  control: Control<TFieldValues>;
  name: TName;
  label: string;
}

export const InputWithLabel = ({
  control,
  name,
  label,
  ...rest
}: TextInputProps) => {

  return (
    <label>
      {label}
      <Controller
        control={control}
        name={name}
        render={({ field, fieldState: { error } }) => (
          <>
            <input {...field} {...rest} />
            {error && <span>{error.message}</span>}
          </>
        )}
      />
    </label>
  );
};

Controller 컴포넌트는 기본적으로 내부에서 useController를 호출하고, 필요한 props를 자식 컴포넌트에게 전달하는 형태이다.
Controller 컴포넌트의 render prop이 현재 필드의 상태와 함수들을 담은 field 객체와 에러 상태를 담은 fieldState 객체를 인자로 받는 함수를 요구하게 되고, 이 함수가 입력 필드를 렌더링해서 필요한 경우 에러 메시지를 보여준다.
useController를 직접 사용하는 것에 비해 조금 더 간단하고 명시적으로 컴포넌트를 작성할 수 있는 것이 특징이다.

profile
☁️

0개의 댓글