Frontend

React Hook Form으로 폼 관리

Leo(상원) 2024. 8. 9. 18:45
반응형

이번 포스팅에서는 React Hook Form 라이브러리를 활용해 폼(Form)을 효율적으로 개선하는 방법에 대해 다뤄보려고 합니다.

 

제가 회사에 입사하고 처음 맡았던 업무는 관리자 페이지를 만드는 것이었습니다. 관리자 페이지는 다양한 폼 기능을 포함하고 있어, 로직을 구현하는 과정에서 여러 가지 고민을 하게 되었습니다. 그러던 중 눈에 띈 라이브러리가 바로 React Hook Form이었습니다. 이 라이브러리를 실제 프로젝트에 적용해 보니, 이전에 제가 겪었던 문제들을 해결하는 데 큰 도움이 되었습니다.

 

이 글을 통해 React Hook Form을 어떻게 활용해 폼을 개선하고, 상태 관리를 효율적으로 할 수 있는지 알아보겠습니다. 일반적으로 React에서 폼을 사용한다면 어떤 형태로 구현하는지부터 시작해보겠습니다.

 

import React from 'react';

export default function playground() {
  const [form, setForm] = React.useState({
    name: '',
    email: '',
    password: ''
  });

  const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
    const { name, value } = e.target;
    setForm({
      ...form,
      [name]: value
    });
  };

  return (
    <form>
      <input type="text" name="email" value={form.email} onChange={handleChange} />

      <input type="text" name="name" value={form.name} onChange={handleChange} />

      <input type="text" name="password" value={form.password} onChange={handleChange} />
    </form>
  );
}

 

물론 간단한 폼이라면 문제가 되지 않지만, 폼이 더 복잡하고 길어지면 상태를 여러 컴포넌트에 props로 전달해야 하는 상황이 발생할 수 있습니다. 

 

예를 들어, 다음과 같은 코드에서 이러한 문제를 확인할 수 있습니다.

<form>
  <UserInfoComponent form={form} onChange={handleChange} />

  <ProductComponent form={form} onChange={handleChange} />

  <DeliveryComponent form={form} onChange={handleChange} />
</form>

 

폼이 길어지면 여러 컴포넌트에 동일한 props를 반복해서 전달해야 하는 상황이 발생할 수 있습니다. 

 

물론, Custom Hook을 사용하여 이를 해결할 수 있지만, 다음과 같은 단점이 존재합니다.

// useHookForm.ts
export function useHookForm() {
  const [form, setForm] = useState<IForm>({
    name: '',
    email: '',
    password: '',
    productName: '',
    productPrice: 0,
    deliveryName: '',
    deliveryAddress: ''
  });

  const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
    const { name, value } = e.target;
    setForm({
      ...form,
      [name]: value
    });
  };

  return { form, handleChange };
}

// userInfo.tsx
function UserInfoComponent() {
  const { form, handleChange } = useHookForm();

  return (
    <>
      <input type="text" name="name" value={form.name} onChange={handleChange} />

      <input type="text" name="email" value={form.email} onChange={handleChange} />

      <input type="text" name="password" value={form.password} onChange={handleChange} />
    </>
  );
}

// product.tsx
function ProductComponent() {
  const { form, handleChange } = useHookForm();

  return (
    <>
      <input type="text" name="productName" value={form.productName} onChange={handleChange} />

      <input type="text" name="productPrice" value={form.productPrice} onChange={handleChange} />
    </>
  );
}

// delivery.tsx
function DeliveryComponent() {
  const { form, handleChange } = useHookForm();

  return (
    <>
      <input type="text" name="deliveryName" value={form.deliveryName} onChange={handleChange} />

      <input type="text" name="deliveryAddress" value={form.deliveryAddress} onChange={handleChange} />
    </>
  );
}

 

이와 같이 작성하면 각 컴포넌트가 동일한 form 상태를 공유하지 못하게 됩니다. 

 

동일한 상태를 공유하려면 form의 상태를 전역으로 관리해야 합니다. 그러나, 이러한 문제를 해결할 수 있는 방법이 바로 React Hook Form입니다.

기본 사용 방법

아래는 React Hook Form의 공식 문서에서 제공하는 기본 사용 예시입니다.

export default function playground() {
  const {
    register,
    handleSubmit,
    formState: { errors }
  } = useForm<IForm>({
    defaultValues: initialForm
  });

  const onSubmit = (data: IForm) => {};

  return (
    <form onSubmit={handleSubmit(onSubmit)}>
      <input type="text" {...register('email')} />
      <input type="text" {...register('name')} />
      <input type="text" {...register('password')} />
      <input type="text" {...register('productName')} />
      <input type="text" {...register('productPrice')} />
      <input type="text" {...register('deliveryName')} />
      <input type="text" {...register('deliveryAddress')} />
    </form>
  );
}

 

register 메서드는 다양한 기능을 제공하지만, 중요한 점은 value를 직접 관리하지 않는다는 것입니다. 

 

React Hook Form은 ref를 사용해 상태를 제어하므로, 기존의 value를 변경해도 리액트가 직접적으로 상태를 변경하지 않으며, 불필요한 렌더링을 피할 수 있습니다. 대신, ref를 활용해 비제어 상태로 메모리에 저장된 값을 필요할 때 가져와 사용함으로써 렌더링을 최소화하는 장점이 있습니다. 

 

그러나, 기본 예시만으로는 React Hook Form을 사용할 메리트가 크게 와닿지 않을 수 있습니다.

Controller

Controller 컴포넌트는 React Hook Form에서 제공하는 중요한 컴포넌트입니다. 

 

React Hook Form은 기본적으로 비제어 상태를 관리하는 라이브러리이지만, MUI와 같은 UI Kit을 사용할 때는 제어 컴포넌트를 활용해야 합니다. 이때 Controller를 사용하면 해당 필드의 상태를 추적하고, 필요한 경우에만 업데이트를 진행할 수 있습니다.

 

Controller를 사용하면 React Hook Form의 장점을 극대화할 수 있습니다. 이제 Controller의 사용법을 살펴보겠습니다.

<Controller
  name="name"
  control={control}
  render={({ field }) => (
    <CustomInput
      type="text"
      inputName={field.name}
      inputValue={field.value}
      onChangeEvent={field.onChange}
    />
  )}
/>

 

Controller는 name, control, render 세 가지 주요 프로퍼티를 가지고 있습니다. 

 

이 프로퍼티들은 register와 달리 field를 통해 커스텀 컴포넌트에 유연하게 대응할 수 있게 해주며, 코드의 가독성도 높여줍니다. 다만, Controller를 많이 사용하면 폼이 길어질 수 있다는 단점이 있습니다. 

 

Controller를 더 효율적으로 사용하는 방법에 대해서는 이후에 자세히 설명하겠습니다.

watch와 useWatch

watch는 React Hook Form에서 제공하는 함수로, 비제어 상태인 폼에서 실시간으로 값을 확인할 수 있도록 도와줍니다. 

 

이 함수는 옵저버 패턴을 사용하여 특정 필드를 구독하고, 상태 변화를 감지합니다. 그러나, watch는 상태 변화를 감지할 때 모든 폼 컴포넌트를 다시 렌더링하기 때문에 성능 문제가 발생할 수 있습니다.

 

이러한 성능 이슈를 해결하기 위해 useWatch가 사용됩니다. useWatch는 특정 컴포넌트 내에서만 리렌더링을 발생시키므로, 상태 변화를 감지하고 특정 로직을 실행해야 할 때 사용하는 것이 좋습니다.

 

다음은 watch와 useWatch의 사용 예시입니다.

// watch의 사용법
const { watch } = useForm<IForm>({
  defaultValues: initialForm
});

const watchName = watch('name');

// useWatch의 사용법
const { control } = useForm<IForm>({
  defaultValues: initialForm
});

const watchName = useWatch({
  name: 'name',
  control
})

FormProvider와 useFormContext

FormProvider와 useFormContext는 React Hook Form에서 지원하는 전역 상태 훅입니다. 리액트에서 Context API를 사용해본 분이라면 쉽게 이해할 수 있는 개념입니다. 제가 이 라이브러리를 사용하는 주요 이유 중 하나는 바로 이 기능입니다.

 

특히, 매우 복잡하고 광범위한 폼을 다룰 때, 이 기능을 사용하면 props 전달의 번거로움을 줄이고, 상태를 한곳에서 관리할 수 있어 매우 유리합니다.

 

예시를 통해 좀 더 자세히 살펴보겠습니다.

export const usePlayGroundFormContext = () => useFormContext<IForm>();

export default function playground() {
  const method = useForm<IForm>({
    defaultValues: initialForm
  });

  return (
    <FormProvider {...method}>
      <form>
        <UserInfoComponent />

        <ProductComponent />

        <DeliveryComponent />
      </form>
    </FormProvider>
  );
}

function UserInfoComponent() {
  return (
    <>
      <input type="text" name="name" />

      <input type="text" name="email" />

      <input type="text" name="password" />
    </>
  );
}

function ProductComponent() {
  return (
    <>
      <input type="text" name="productName" />

      <input type="text" name="productPrice" />
    </>
  );
}

function DeliveryComponent() {
  return (
    <>
      <input type="text" name="productName" />

      <input type="text" name="productPrice" />
    </>
  );
}

 

각각의 역할에 맞는 입력 필드를 담당하는 컴포넌트가 있다면, 원래는 각 컴포넌트에 form의 값과 상태를 변경하는 함수를 props로 전달해야 했습니다. 

 

하지만 useFormContext와 FormProvider를 사용하면, 이러한 번거로움을 줄일 수 있습니다. FormProvider로 감싸진 모든 컴포넌트에서 useForm 메서드를 통해 form의 값을 추적하고 관리할 수 있게 됩니다.

function UserInfoComponent() {
  const { register } = usePlayGroundFormContext();

  return (
    <>
      <input type="text" {...register('name')} />
      <input type="text" {...register('email')} />
      <input type="text" {...register('password')} />
    </>
  );
}

function ProductComponent() {
  const { control } = usePlayGroundFormContext();

  return (
    <>
      <Controller name="productName" control={control} render={({ field }) => <input {...field} />} />
      <Controller name="productPrice" control={control} render={({ field }) => <input {...field} />} />
    </>
  );
}

 

위 코드에서 보듯이, useFormContext를 활용해 생성한 usePlayGroundFormContext를 각 컴포넌트에서 호출하면, 불필요한 props 전달을 줄일 수 있습니다. 이를 통해, 각기 다른 컴포넌트에서도 동일한 form 상태를 공유할 수 있습니다.

useController

useController는 useFormContext와 함께 사용하면 더욱 유연하게 상태를 관리할 수 있습니다. 만약 Controller 컴포넌트가 너무 복잡해 보인다면, useController를 대안으로 사용할 수 있습니다. 또한, onChange 내부에 추가 로직을 포함시켜 더욱 유연하게 대처할 수 있습니다.

 

먼저, 예시를 살펴보겠습니다.

function UserInfoComponent() {
  const { control } = usePlayGroundFormContext();

  const { field: nameField } = useController({
    name: 'name',
    control
  });

  const { field: emailField } = useController({
    name: 'email',
    control
  });

  const { field: passwordField } = useController({
    name: 'password',
    control
  });

  // 비밀번호의 경우 숫자와 특수문자만 입력 가능하도록 제한
  const handlePasswordChange = (e: React.ChangeEvent<HTMLInputElement>) => {
    const { value } = e.target;
    if (!/^[0-9!@#$%^&*()_+\-=\[\]{};':"\\|,.<>\/?]*$/.test(value)) {
      return;
    }

    passwordField.onChange(e);
  };

  return (
    <>
      <input type="text" name={nameField.name} value={nameField.value} onChange={nameField.onChange} />
      <input type="text" name={emailField.name} value={emailField.value} onChange={emailField.onChange} />
      <input type="text" name={passwordField.name} value={passwordField.value} onChange={handlePasswordChange} />
    </>
  );
}

 

useController를 사용하면, control과 name을 지정한 후 field 메서드를 통해 Controller 컴포넌트를 사용하지 않고도 동일한 기능을 구현할 수 있습니다. 또한, 비밀번호와 같은 입력 필드에 추가적인 onChange 로직이 필요하다면, handlePasswordChange 함수와 같이 작성하여 원하는 기능을 추가할 수 있습니다.

useFieldArray

폼을 사용하다 보면 객체 내에 배열이 포함된 경우가 많습니다. 예를 들어, 상품을 등록할 때 옵션이나 상품 사진을 배열로 관리해야 할 수 있습니다. 이러한 상황에서 유용하게 사용할 수 있는 훅이 바로 useFieldArray입니다.

 

먼저, 예시 코드를 살펴보겠습니다.

function ProductComponent() {
  const { control } = usePlayGroundFormContext();

  const { field: nameField } = useController({
    name: 'productName',
    control
  });
  const { field: priceField } = useController({
    name: 'productPrice',
    control
  });
  const {
    fields: productOptionsFields,
    append,
    remove
  } = useFieldArray({
    name: 'productOptions',
    control
  });

  return (
    <>
      <input type="text" name={nameField.name} value={nameField.value} onChange={nameField.onChange} />
      <input type="number" name={priceField.name} value={priceField.value} onChange={priceField.onChange} />

      {productOptionsFields.map((field, index) => (
        <div className="option-card">
          <div key={field.id}>
            <input type="text" name={`productOptions[${index}].name`} value={field.name} />
            <input type="text" name={`productOptions[${index}].value`} value={field.value} />
            <button type="button" onClick={() => remove(index)}>
              삭제
            </button>
          </div>
        </div>
      ))}

      <button type="button" onClick={() => append({ name: '', value: '' })}>
        추가
      </button>
    </>
  );
}

 

fields 객체는 append, prepend, remove, swap, move, insert, update, replace 등의 메서드를 포함하고 있어, 상황에 맞게 배열을 매우 간편하게 관리할 수 있습니다.

 

또한, 보통 map을 사용할 때 key에 고유한 값을 넣어줘야 하지만, 많은 사람들이 실수로 index를 사용하기도 합니다. 그러나 공식 문서에서는 key 값에 index를 사용하는 것을 권장하지 않습니다. 다행히도, useArrayField는 이러한 문제를 해결하기 위해 자동으로 고유한 id를 생성해줍니다.

유효성 검사

React Hook Form에서 유효성 검사는 매우 간단합니다. 유효성 검사를 구현하는 방법은 다양하지만, 먼저 useController를 활용한 기본적인 유효성 검사 예시를 보여드리겠습니다.

function ProductComponent() {
  const { control } = usePlayGroundFormContext();

  const {
    field: nameField,
    fieldState: { error: nameError }
  } = useController({
    name: 'productName',
    control,
    rules: { required: '상품명을 입력해주세요.', maxLength: { value: 10, message: '상품명은 10자 이내로 입력해주세요.' } }
  });
  const {
    field: priceField,
    fieldState: { error: priceError }
  } = useController({
    name: 'productPrice',
    control,
    rules: { required: '가격을 입력해주세요.' }
  });

  return (
    <>
      <TextField type="text" name={nameField.name} value={nameField.value} onChange={nameField.onChange} helperText={nameError?.message} />
      <TextField type="number" name={priceField.name} value={priceField.value} onChange={priceField.onChange} helperText={priceError?.message} />
    </>
  );
}

 

해당하는 productName 필드에 rules를 추가하여, 필수 입력이 누락되거나 10글자를 초과할 경우 오류 메시지가 표시되도록 설정할 수 있습니다. 그러나, 

 

React Hook Form의 공식 문서에서는 스키마 기반의 유효성 검사를 지원한다고 명시하고 있습니다. 대표적으로 yup, zod 등의 라이브러리가 있습니다.

 

저는 이 중에서 yup 라이브러리를 사용해, 더 효율적으로 유효성 검사를 구현해보겠습니다.

const schema = yup.object().shape({
  productName: yup.string().required('상품명을 입력해주세요').max(10, '10자 이내로 입력해주세요'),
  productPrice: yup.number().required('가격을 입력해주세요')
});

const method = useForm<IForm>({
  defaultValues: initialForm,
  resolver: yupResolver(schema as any)
});

 

이렇게 스키마 형태로 관리하게 되면 가독성과 폴더를 분리해서 관심사 분리도 가능한 효율적인 유효성 검사가 가능하게 됩니다.


관리자 페이지에 React Hook Form을 도입하면서 고민했던 부분들과 이를 개선한 과정을 공유해보았습니다. 아마 많은 개발자 분들도 각자의 고민을 통해 자신만의 해결 방법을 찾아가고 계실 것입니다. 물론, 제가 적은 글이 절대적인 정답은 아닙니다.

 

이 포스팅을 통해, 아직 폼에 대한 고민이 많으신 분들께 “이런 방법도 있구나” 하는 아이디어를 제공하고자 했습니다. 이 글이 도움이 되길 바라며, 저는 이만 물러가겠습니다. 감사합니다.

반응형