ABOUT ME

-

Today
-
Yesterday
-
Total
-
  • React controlled Form vs uncontrolled Form
    Article 분석 2025. 1. 31. 01:47
    React-hook-form을 공부하던 와중 옛날에 끝까지 분석하지 못한 article이 있어서 이를 분석하고 좀 더 나은 방법 및 form을 다루는 방식을 좀 더 세세하게 알아보자고 생각이 들어서 옛 글을 업데이트 하려고 함

     

    출처: A Better Guide To Forms in React

     

    이 글의 시작은 여러 모범 사례들과 Server Component에 대한 내용을 언급하면서 대부분의 양식은 오래된 리소스라고 하면서 React에서 Controlled 방식과 Uncontrolled 방식으로 form을 다루는 방식의 차이점을 설명하면서 시작된다.

     

    1️⃣ React controlled Form vs uncontrolled Form

    📦 React에서 Controlled 방식은 State를 이용한 방식이다.

    각 입력 값을 State로 저장한 뒤 State의 변화에 따라서 리랜더링이 일어나 입력 값이 즉시 반영될 수 있게 해준다.

    import React, { useState } from 'react';
    
    function ControlledForm() {
      const [value, setValue] = useState('');
    
      const handleChange = (event) => {
        setValue(event.target.value);
      };
    
      const handleSubmit = () => {
        // no "submit event" param needed above ^
        sendInputValueToApi(value).then(() => /* Do something */)
      };
    
      return (
        <>
          <input type="text" value={value} onChange={handleChange} />   
          <button onClick={handleSubmit}>Send</button>
        </>
    }

    위 코드는 해당 article에서 제공하는 controlledForm에 대한 예시이다. 보시다시피 useState를 통해서 해당 입력값은 사용자의 입력에 따라 매번 업데이트 되므로 불필요한 리랜더링을 많이 발생시킨다. 사용자의 submit event에 대해서만 상태값이 업데이트되도록하면 가장 최선이라고 생각이 된다.

    해당 글에서도 이러한 controlled방식의 단점을 3가지로 지적한다. 

    1. 사용자가 입력할 때마다 리랜더링을 발생시키고 싶지 않을 수 있을 때에는 비효율적이다.
    2. 복잡한 form을 관리하려면 많은 코드를 작성해야한다.
    3. 필드 수가 가변적일 경우 복잡해지며, 많은 보일러플레이트 및 비즈니스 로직이 생길 수 있다.
    더보기

    1, 2번은 쉽게 이해할 수 있는 내용이지만 3번에 대해서는 조금 설명이 필요해보인다. 
    input의 개수가 가변적인 경우에는 useState가 그만큼 늘어나야하고, 이를 방지하려면 객체를 useState에 값으로 넣어야하는데 이는 어느 input에 내용을 수정하게 되면 결국 상태 값이 전체적으로 변하게 되고 이를 메모이제이션과 같은 방식으로 해결해보려한다면 보일러 플레이트 요소가 생길 수 있다는 얘기...

    ...
    function CumbersomeForm() {
      const [formData, setFormData] = useState({
        firstName: "",
        lastName: "",
        email: "",
        address: "",
        // ... potentially many more individual properties
      });
      ...
    
      return (
        ...
      );
    }

     

    위 코드를 통해 간단히 이해할 수 있다.

     


     

    📦 Uncontrolled 방식은 vanilla HTML 및 Javascript 기능을 사용하여 데이터를 관리한다.

    이는 onsubmit 이벤트 리스너를 폼에 추가하고 제출 버튼을 눌렀을 때 handler함수를 실행하는 방식이다.

    import React from "react";
    
    function UncontrolledForm() {
      const handleSubmit = (event) => {
        event.preventDefault();
        const formData = new FormData(event.target);
        for (let [key, value] of formData.entries()) {
          console.log(`${key}: ${value}`);
        }
      };
    
      return (
        <form onSubmit={handleSubmit}>
          <label>First Name:</label>
          <input type="text" name="firstName" />
          ...
          <label>Address:</label>
          <input type="text" name="address" />
          {/* ... potentially many more individual input fields */}
          <button type="submit">Submit</button>
        </form>
      );
    }

    가장 큰 차이점으로 보이는 것은 controlled 방식에서는 state로 관리하던 객체값이 단순히 new FormData(event.target) 이부분으로 깔끔하게 사용되었다는 점이다. 이는 이후 entries() 메서드를 통해서 key와 value에 대한 값을 핸들링 가능하다는 점을 보인다.

    원작자는 Uncontrolled 방식을 설명하는 부분에서 긍정적인 의견을 많이 보인다. 하지만 이 방식도 실시간으로 validation을 해줄 수 없다는 단점이 보이기에 무조건 적용사항은 아니라고 생각된다.

     

    ⛔️ 주의사항 Ref를 사용하지 마세요

    실제 React 공식홈페이지에서 비제어 컴포넌트에 대한 내용을 보면 Ref를 통해서 구현을 하는 모습을 볼 수 있다. 하지만 원작자는 Ref를 사용하지 말라고 경고를 하고 있다.

    원작자는 useRef를 호출함으로써 useState와 동일한 issue 및 보일러 플레이트가 생성될 수 있고 new FormData()만으로도 해결을 할 수 있다고 말하고 있다.

     

    Ref가 사용되면 유용한 경우를 따로 설명을 하는 것을 보니 단순한 form을 사용하는 경우에서는 new FormData()가 더 적합하다고 생각하는 것 같다. 코드로 보고 이해하는 편이 더 간단한 것 같아서 코드를 가져왔다.

    더보기
    // 1. focus를 관리해야하는 경우
    function MyForm() {
      const inputRef = useRef(null);
    
      const focusInput = () => {
        inputRef.current.focus();
      };
    
      return (
        <form>
          <input ref={inputRef} type="text" />
          <button type="button" onClick={focusInput}>
            Focus Input
          </button>
        </form>
      );
    }
    
    // 2. 부모요소에서 자식요소의 입력값을 불러오는 경우
    const ChildComponent = React.forwardRef((props, ref) => (
      <input ref={ref} type="text" />
    ));
    
    function MyForm() {
      const inputRef = useRef(null);
    
      const focusInput = () => {
        inputRef.current.focus();
      };
    
      return (
        <form>
          <ChildComponent ref={inputRef} />
          <button type="button" onClick={focusInput}>
            Focus Input
          </button>
        </form>
      );
    }

     


     

    📦 Mixcontrolled 방식은 복잡한 값의 경우에는 State를 사용하고 그렇지 않고 간단한 값의 경우에는 new FormData()를 사용하는 경우이다.

    원작자가 예시를 든 경우는 전화번호의 경우에는 state를 통해서 validation을 진행하고 나머지 값은 Uncontrolled 방식으로 구현을 하는데 사실 이러한 방법이 좋을지는 잘 모르겠다. 단순히 로그인을 할 경우에는 Uncontrolled방식을 사용해도 되지만 회원가입의 경우에는 validation할 값도 많아서 오히려 Mix방식은 재사용성에 방해될 수도 있다고 느껴지기 때문이다.

    const PhoneInput = () => {
      const [phoneNumber, setPhoneNumber] = useState("");
    
      const handlePhoneNumberChange = (event) => {
        // Some function to format the phone number
        const formattedNumber = formatPhoneNumber(event.target.value);
        setPhoneNumber(formattedNumber);
      };
    
      return (
        <input
          type="tel"
          name="phoneNumber"
          value={phoneNumber}
          onChange={handlePhoneNumberChange}
        />
      );
    };
    
    function MixedForm() {
      const handleSubmit = (event) => {
        event.preventDefault();
        const formData = new FormData(event.target);
        for (let [key, value] of formData.entries()) {
          console.log(`${key}: ${value}`);
        }
      };
    
      return (
        <form onSubmit={handleSubmit}>
          <label>Name:</label>
          <input type="text" name="name" />
    
          <label>Email:</label>
          <input type="email" name="email" />
    
          <label>Phone Number:</label>
          <PhoneInput />
    	  ...
        </form>
      );
    }
    
    // Helper function for phone number formatting (just an example)
    function formatPhoneNumber(number) {
      // Format the phone number as desired. This is just a basic example.
      return number.replace(/\D/g, "").slice(0, 10);
    }

    위 코드와 같이 리랜더링할 요소부분만 따로 컴포넌트로 분리한다면 리랜더링과 State를 지역적으로 사용하게 될 것 같긴 하다.

    하지만 위 코드를 보면 드는 의문점은 state로 관리하고 있는 phoneNumber에 대한 정보를 따로 전달하는 코드가 없다는 점이다. 이는 React 컴포넌트 구조는 브라우저에 실제로 랜더링되는 HTML에 영향을 주지 않는다는 점을 통해서 useState를 통해서 독립적인 상태를 가지고 있더라도 결국 submit event시에 name을 가진 input으로 생각하고 이를 자동으로 formData에 추가한다.

     


     

    📦 오류 처리에 대해서 사용자가 정의하거나 React를 이용하지 않아도 기본적으로 form에 내장되어있는 속성을 사용해서 문제를 해결할 수 있다고 한다.

    하지만 이 또한 실시간성이 아닌 단순히 onsubmit시에 처리하는 방식으로 좀 더 간단한 form의 경우에 적용하면 될 것 같다.

    function UncontrolledForm() {
      const [errors, setErrors] = useState({});
    
      const handleSubmit = (event) => {
        event.preventDefault();
        const formData = new FormData(event.target);
    
        let validationErrors = {};
    
        // Custom validation: Ensure the email domain is "example.com"
        const email = formData.get("email");
        if (email && !email.endsWith("@example.com")) {
          validationErrors.email = "Email must be from the domain example.com.";
        }
    
        if (formData.get("phoneNumber").length !== 10) {
          validationErrors.phoneNumber = "Phone number must be 10 digits.";
        }
    
        if (Object.keys(validationErrors).length > 0) {
          setErrors(validationErrors);
        } else {
          // Handle the successful form submission, e.g., sending formData to a server
          console.log(Array.from(formData.entries()));
          setErrors({}); // Clear any previous errors
        }
      };
    
      return (
        <form onSubmit={handleSubmit}>
          <label>Name:</label>
          <input type="text" name="name" required />
          {errors.name && <div className="error">{errors.name}</div>}
    
          <label>Email (must be @example.com):</label>
          <input type="email" name="email" required />
          {errors.email && <div className="error">{errors.email}</div>}
    
          <label>Phone Number (10 digits):</label>
          <input type="tel" name="phoneNumber" required pattern="\d{10}" />
          {errors.phoneNumber && <div className="error">{errors.phoneNumber}</div>}
    
          <button type="submit">Submit</button>
        </form>
      );
    }
    
    export default UncontrolledForm;

     


    2️⃣ 다양한 라이브러리 및 프레임워크

    📦 React Server Component, Next.js 즉 Server단에서 form을 사용하는 방식을 통해서 지연시간을 단축시키고 더 빠른 성능을 낼 수 있다.

    // page.jsx
    import { PhoneInput } from "./PhoneInput";
    
    export default function Page() {
      async function create(formData: FormData) {
        "use server";
    
        // ... use the FormData
      }
    
      return (
        <form action={create}>
          <label>Name:</label>
          <input type="text" name="name" />
    
          <label>Email:</label>
          <input type="email" name="email" />
    
          <label>Phone Number:</label>
          <PhoneInput />
    
          <label>Address:</label>
          <input type="text" name="address" />
    
          <button type="submit">Submit</button>
        </form>
      );
    }
    
    // PhoneInput.jsx
    "use client";
    
    // Helper function for phone number formatting (just an example)
    function formatPhoneNumber(number) {
      // Format the phone number as desired. This is just a basic example.
      return number.replace(/\D/g, "").slice(0, 10);
    }
    
    import { useState } from "react";
    
    export const PhoneInput = () => {
      const handlePhoneNumberChange = (event) => {
        // Some function to format the phone number
        const formattedNumber = formatPhoneNumber(event.target.value);
        setPhoneNumber(formattedNumber);
      };
      const [phoneNumber, setPhoneNumber] = useState("");
    
      return (
        <input
          type="tel"
          name="phoneNumber"
          value={phoneNumber}
          onChange={handlePhoneNumberChange}
        />
      );
    };

    이는 Next.js의 예제로 server action을 이용해서 form을 다루는 간단한 방법이다.

     

     

    React 생태계에는 많은 form library가 있다. (특히 React-hook-form) 하지만 이러한 라이브러리를 사용하지 않고도 얼마든지 구현할 수 있다는 내용을 알려주며, 오히려 더 간단하게 구현할 수도 있다는 내용이다. form과 input에 대해서 꼭 라이브러리가 필요한지 여부를 한 번 더 생각하면 프로젝트에 적용해보자.

     

    댓글

Designed by Tistory.