제어 컴포넌트와 비제어 컴포넌트 제대로 이해하기 (feat. React Hook Form)

최소희·2024년 10월 9일
0

프론트엔드 학습

목록 보기
18/23
post-thumbnail

개요

최근에 한 개발자 지인과 제어 컴포넌트(controlled components)와 비제어 컴포넌트(uncontrolled components)에 관한 이야기를 나누게 되었다.

때마침 React Hook Form을 사용하여 폼을 관리하는 개발을 하고 있었기에, 제어 컴포넌트와 비제어 컴포넌트를 이해하고 React Hook Form에서는 두 개발 방법론을 어떻게 적용하였는지 이해하기 위해 이 글을 작성하게 되었다.

이 글은 다음 내용을 중점적으로 다룬다.

  • 제어 컴포넌트와 비제어 컴포넌트의 개념 소개
  • React에서 제어와 비제어 컴포넌트의 동작 방식
  • 언제 어떤 컴포넌트를 선택해야 하는지에 대한 가이드
  • React Hook Form을 통한 제어와 비제어 컴포넌트 관리 방법

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

difference between controlled and uncontrolled ones

두 컴포넌트의 차이점을 시각적으로 묘사해 보았다.

간단히 말해,

  • 제어 컴포넌트는 React가 컴포넌트의 상태(state)를 관리하는 반면,
  • 비제어 컴포넌트는 React에게 관리 권한을 위임하지 않고, DOM과 직접 상호작용함으로써 상태를 관리한다.

좀 더 자세히 알아보자.

제어 컴포넌트 이해하기

앞서 제어 컴포넌트는 React가 컴포넌트의 상태를 관리한다고 했다. 이는 컴포넌트의 상태가 useState 등을 통해 React 내부에서 관리된다는 의미이다. 우리가 흔히 사용하는 방식으로, props와 state를 통해 상태를 제어한다.

onChange 이벤트 핸들러 등을 통해 상태를 직접 변경하며, 상태가 변경되면 React는 자동으로 해당 변경 사항을 반영하기 위해 리렌더링한다.

제어 컴포넌트의 특징

1. 상태의 중앙 집중화

제어 컴포넌트는 컴포넌트의 상태에서 관리할 데이터를 관리하기 때문에 더 쉽게 관리 및 업데이트할 수 있다.

아래 예시 코드는 inputValue 상태가 React에 의해 관리되어, 현재 값을 쉽게 조작할 수 있다.

import { ChangeEvent, useState } from "react";

const ControlledComponent = () => {
  const [inputValue, setInputValue] = useState("");

  const handleChange = (event: ChangeEvent<HTMLInputElement>) => {
    setInputValue(event.target.value);
  };

  return (
    <div>
      <input type="text" value={inputValue} onChange={handleChange} />
      <p>입력한 값: {inputValue}</p>
    </div>
  );
};

export default ControlledComponent;

2. 쉬운 유효성 검사

제어 컴포넌트는 유효성 검사(validation check)를 이벤트 핸들러 내에서 개발자가 직접 처리할 수 있다.

import { ChangeEvent, useState } from "react";

const LIMIT_LENGTH = 10;

const ControlledComponent = () => {
  const [inputValue, setInputValue] = useState("");
  const [error, setError] = useState("");
  const handleChange = (event: ChangeEvent<HTMLInputElement>) => {
    const value = event.target.value;

    setInputValue(value);

    if (value.length < LIMIT_LENGTH) {
      setError("");
    } else setError("10자 이하로 입력해주세요.");
  };

  return (
    <div>
      <input type="text" value={inputValue} onChange={handleChange} />
      <p>입력한 값: {inputValue}</p>
      <p>{error}</p>
    </div>
  );
};

export default ControlledComponent;

3. 디버깅과 테스트가 편리함

React에서 상태를 명시적으로 관리하기 때문에 상태 변화를 명확하게 추적할 수 있다.

또한, 데이터가 컴포넌트의 상태를 통해 흐르기 때문에 데이터 흐름이 일관되고 예측 가능하다.

이러한 이유로 디버깅과 테스트가 더 편리하다.

4. 일관된 UI 업데이트

당연히 상태 변경에 따라 리렌더링이 되는 구조이기 때문에, 컴포넌트의 상태와 UI가 동기화된다.

아래 예시 코드는 입력 필드의 유효성 검사가 통과되어야만 버튼이 활성화되는 모습을 보여준다.

즉, 상태(state)와 UI가 동기화되는 것을 볼 수 있다.

import { ChangeEvent, useState } from "react";

const LIMIT_LENGTH = 10;

const ControlledComponent = () => {
  const [inputValue, setInputValue] = useState("");
  const [error, setError] = useState("");
  const [isDisabled, setIsDisabled] = useState(true);

  const handleChange = (event: ChangeEvent<HTMLInputElement>) => {
    const value = event.target.value;

    setInputValue(value);

    if (value.length < LIMIT_LENGTH) {
      setError("");
      setIsDisabled(false); // 유효성 검사 통과 시 버튼 활성화
    } else {
      setError("10자 이하로 입력해주세요.");
      setIsDisabled(true); // 유효성 검사 실패 시 버튼 비활성화
    }
  };

  return (
    <div>
      <input type="text" value={inputValue} onChange={handleChange} />
      <p>입력한 값: {inputValue}</p>
      <p>{error}</p>
      <button disabled={isDisabled}>제출</button>
    </div>
  );
};

export default ControlledComponent;

5. 외부 라이브러리와 통합을 통한 확장성 용이

Redux, Zustand와 같은 상태 관리 라이브러리를 통해 상태 관리를 확장할 수 있다.

규모가 큰 애플리케이션의 경우 이러한 라이브러리 도입을 통해 직접 상태 관리를 제어함과 동시에 복잡성을 줄일 수 있다.

비제어 컴포넌트 이해하기

비제어 컴포넌트는 데이터를 컴포넌트의 상태(state)가 아닌 DOM에서 직접 관리된다.

일반적으로 DOM의 데이터에 접근하기 위해 ref를 사용한다.

제어 컴포넌트와 비교하면 구현이 더 간단하지만, 몇 가지 차이점이 있다.

비제어 컴포넌트의 특징

1. 간단한 구현

비제어 컴포넌트는 상태를 관리할 필요가 없기 때문에 구현이 간단하다.

추가적인 코드(useState 로 상태를 관리 등)를 작성하지 않고도, DOM 요소의 값을 직접 참조할 수 있다.

아래 코드는 ref를 통해 input 값에 직접 접근하고 있다.

import { FormEvent, useRef } from "react";

const UncontrolledForm = () => {
  const inputRef = useRef<HTMLInputElement>(null);

  const handleSubmit = (event: FormEvent<HTMLFormElement>) => {
    event.preventDefault();
    alert("입력된 값: " + inputRef.current?.value);
  };

  return (
    <form onSubmit={handleSubmit}>
      <label>
        이름:
        <input type="text" ref={inputRef} />
      </label>
      <button type="submit">제출</button>
    </form>
  );
};

export default UncontrolledForm;

2. 성능 향상

제어 컴포넌트는 state가 변할 때마다 컴포넌트가 리렌더링된다.

그러나, 비제어 컴포넌트는 상태 업데이트가 필요 없으므로 렌더링 성능이 향상된다.

입력 필드가 많은 폼에서는 이러한 측면에서 매우 유용하다.

앞서 예시로 든 제어 컴포넌트와 비제어 컴포넌트의 함수 블럭에 console.log를 추가하고, 입력 필드에 데이터를 입력해보자.

제어 컴포넌트:

const ControlledComponent = () => {
	const [inputValue, setInputValue] = useState("");

  console.log("제어 컴포넌트 렌더링");

  // 나머지 코드...
};

비제어 컴포넌트:

const UncontrolledComponent = () => {
	 const inputRef = useRef<HTMLInputElement>(null);

  console.log("비제어 컴포넌트 렌더링");

  // 나머지 코드...
controlled-componentsuncontrolled-components

입력 필드에 데이터를 입력하면 제어 컴포넌트는 입력할 때마다 console.log 가 출력되지만,

비제어 컴포넌트는 렌더링이 일어나지 않기 때문에 console.log 가 추가로 출력되지 않는다.

3. DOM에 직접 접근

비제어 컴포넌트는 DOM 요소에 직접 접근할 수 있기 때문에, DOM을 직접 조작하는 라이브러리(e.g. jQuery, React Hook Form)와 통합하기 용이하다.

언제, 무엇을 써야할까?

제어 컴포넌트와 비제어 컴포넌트는 각각의 장단점이 있으며, 상황에 따라 적합한 방식을 선택하는 것이 중요하다.

제어 컴포넌트를 선택해야 하는 경우

  • 복잡한 유효성 검사가 필요한 경우: 입력 값에 대한 즉각적인 검증이 필요할 때.
  • 상태 기반 로직이 필요한 경우: 입력 값에 따라 다른 컴포넌트를 렌더링하거나 동적인 UI 업데이트가 필요할 때.
  • 데이터 흐름의 예측성이 중요한 경우: 상태 변화를 명확하게 추적하고 싶을 때.

비제어 컴포넌트를 선택해야 하는 경우

  • 간단한 폼을 빠르게 구현해야 할 때.
  • 성능 최적화가 필요한 경우: 입력 필드가 많고, 입력 시에 렌더링 성능이 중요한 경우.
  • 외부 라이브러리와의 통합이 필요한 경우: React Hook Form과 같이 DOM에 직접 접근하는 라이브러리를 사용할 때.

React Hook Form을 통한 폼 관리

React Hook Form 소개

React Hook Form은 React에서 폼을 쉽게 관리하고 유효성 검사를 수행할 수 있게 해주는 라이브러리이다.

React Hook Form은 비제어 컴포넌트를 활용하여 DOM에서 값들을 직접 읽어오고, 불필요한 리렌더링을 방지한다.

입력 필드가 많은 대규모 폼에서도 효율적인 성능을 제공함과 동시에, 개발자들이 사용하기 쉬운 API들(register, watch 등)을 제공하여 간단하게 구현할 수 있게 해준다.

React Hook Form에서 비제어 컴포넌트 사용하기

React Hook Form은 기본적으로 비제어 컴포넌트를 사용한다.

register 함수를 사용하여 폼 필드를 등록하고, 필요한 시점에 폼 데이터를 가져올 수 있다.

비제어 컴포넌트와 비슷하게 ref 를 통해 DOM 요소에 접근하여 값을 가져온다.

handleSubmit 으로 폼 제출 시 유효성 검사 동작을 제어한다.

그리고 폼 데이터를 가져와 onSubmit 함수에 전달해주는 역할을 한다.

import { useForm } from "react-hook-form";

type FormData = {
  firstName: string;
  lastName: string;
};

const UncontrolledFormWithRHForm = () => {
  const { register, handleSubmit } = useForm<FormData>();

  const onSubmit = (data: FormData) => {
    alert("입력된 값: " + JSON.stringify(data));
  };

  return (
    <form onSubmit={handleSubmit(onSubmit)}>
      <label>
        이름:
        <input {...register("firstName")} />
      </label>
      <label>:
        <input {...register("lastName")} />
      </label>
      <button type="submit">제출</button>
    </form>
  );
};

export default UncontrolledFormWithRHForm;

register 함수를 통해 input 요소를 등록하고 있다.

폼 제출 시 handleSubmit이 실행되어 폼 데이터를 가져온다.

React Hook Form에서 제어 컴포넌트 사용하기

때로는 제어 컴포넌트를 사용해야 할 경우도 있다.

React Hook Form은 Controller 컴포넌트로 제어 컴포넌트와 통합할 수 있다.

내부적으로 React의 useState 를 사용하여 제어 컴포넌트와 통합하고, React Hook Form의 상태 관리와 일치하도록 설정한다.

import { useForm, Controller } from "react-hook-form";
import { TextField } from "@material-ui/core";

type FormData = {
  email: string;
};

const ControlledFormWithRHForm = () => {
  const { control, handleSubmit } = useForm<FormData>();

  const onSubmit = (data: FormData) => {
    alert("입력된 값: " + JSON.stringify(data));
  };

  return (
    <form onSubmit={handleSubmit(onSubmit)}>
      <Controller
        name="email"
        control={control}
        defaultValue=""
        render={({ field }) => <TextField {...field} label="이메일" />}
      />
      <button type="submit">제출</button>
    </form>
  );
};

export default ControlledFormWithRHForm;

최근 Headless UI로 각광받는 shadcn/ui에서 제공하는 Form 컴포넌트는 React Hook Form의 Controller를 활용하여 제어 컴포넌트를 통합한다.

shandcn/ui의 form 컴포넌트 코드

출처: https://github.com/shadcn-ui/ui/blob/main/apps/www/registry/default/ui/form.tsx

import {
  Controller,
  ControllerProps,
  FieldPath,
  FieldValues,
  FormProvider,
  useFormContext,
} from "react-hook-form"

import { cn } from "@/lib/utils"
import { Label } from "@/registry/new-york/ui/label"

const Form = FormProvider

type FormFieldContextValue<
  TFieldValues extends FieldValues = FieldValues,
  TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>
> = {
  name: TName
}

const FormFieldContext = React.createContext<FormFieldContextValue>(
  {} as FormFieldContextValue
)

const FormField = <
  TFieldValues extends FieldValues = FieldValues,
  TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>
>({
  ...props
}: ControllerProps<TFieldValues, TName>) => {
  return (
    <FormFieldContext.Provider value={{ name: props.name }}>
      <Controller {...props} />
    </FormFieldContext.Provider>
  )
}

결론

제어 컴포넌트와 비제어 컴포넌트는 각각의 장단점이 있으며, 상황에 맞게 선택하여 사용해야 한다.

제어 컴포넌트는 복잡한 유효성 검사와 데이터 상태 흐름을 파악하기에 유리하지만, 성능 이슈가 발생할 수 있다.

반면에 비제어 컴포넌트는 성능 최적화에 유리하지만, 데이터의 일관성을 유지하는 데 한계가 있다.

React Hook Form은 비제어 컴포넌트의 장점을 활용하여 성능을 최적화하고, Controller 를 통해 제어 컴포넌트와의 통합도 유연하게 제공한다.

이를 통해서, 복잡한 폼 관리와 다양한 상황에 대응할 수 있는 솔루션을 제공한다.

폼 관리가 복잡해지거나 성능 이슈가 발생한다면 React Hook Form을 도입하여 코드의 복잡성을 낮추고 개발 생산성을 높일 수 있다.

참고 자료

profile
프론트엔드 개발자 👩🏻‍💻

0개의 댓글