Controlled and uncontrolled form inputs in React

박세영·2022년 8월 18일
0

Controlled Components

HTML의 폼 요소는 React의 DOM 요소와 다르다. 왜냐하면 form 요소는 자체적으로 내부 상태를 갖는다. 일반적인 HTML 코드에서는 아래와 같은 방식으로 사용자로부터 입력을 받아 form으로 submit할 수 있다.

<form>
  <label>
    Name:
    <input type="text" name="name" />
  </label>
  <input type="submit" value="Submit" />
</form>

위 코드를 React에 삽입했을 때 역시 똑같이 동작한다. 하지만 사용자가 입력한 데이터에 접근해 활용하거나, submit했을 때 동작하는 event handler를 설정하면 더 다양한 작업을 할 수 있다. 그래서 React가 제안한 것이 바로 Controlled Components(제어 컴포넌트)이다.

HTML에서 내부 상태를 갖는 요소에는 <input>, <textarea>, <select>가 있다. 반면, React에서는 변할 수 있는 상태는 useState() 훅을 통해서 관리한다. 우리는 이 두 가지 방식을 결합해서 상태를 더 유연하게 관리할 수 있다.

class NameForm extends React.Component {
  constructor(props) {
    super(props);
    this.state = {value: ''};

    this.handleChange = this.handleChange.bind(this);
    this.handleSubmit = this.handleSubmit.bind(this);
  }

  handleChange(event) {
    this.setState({value: event.target.value});
  }

  handleSubmit(event) {
    alert('A name was submitted: ' + this.state.value);
    event.preventDefault();
  }

  render() {
    return (
      <form onSubmit={this.handleSubmit}>
        <label>
          Name:
          <input type="text" value={this.state.value} onChange={this.handleChange} />
        </label>
        <input type="submit" value="Submit" />
      </form>
    );
  }
}

위 코드에서 input의 value는 항상 this.state.value이다. 따라서 이제 React 내부에서 갖고 있는 상태를 기준으로 상태를 통일할 수 있다. 제어 컴포넌트에서는 키 입력 이벤트에 대응하는 handler를 통해 상태를 관리하기 때문에, 항상 최신 상태를 유지할 수 있다. 따라서 아래와 같은 작업들을 실시간으로 처리할 수 있다.

  • 유효한 입력인지 검증
  • 유요한 데이터를 가질 때까지 submit 비활성화
  • 특정 입력 형식 강제화

Uncontrolled Components

대부분의 경우에서 form을 구현할 때 React에서 상태를 관리하는 Controlled Compoent를 사용하면 된다. 하지만, DOM에서 직접 데이터를 관리하는 비제어 컴포넌트를 사용하는 방법도 있다.
비제어 컴포넌트는 모든 업데이트 이벤트에 handler 콜백함수를 실행하는 것이 아니라 ref를 통해 DOM으로부터 직접 값을 가져온다.

class NameForm extends React.Component {
  constructor(props) {
    super(props);
    this.handleSubmit = this.handleSubmit.bind(this);
    this.input = React.createRef();
  }

  handleSubmit(event) {
    alert('A name was submitted: ' + this.input.current.value);
    event.preventDefault();
  }

  render() {
    return (
      <form onSubmit={this.handleSubmit}>
        <label>
          Name:
          <input type="text" ref={this.input} />
        </label>
        <input type="submit" value="Submit" />
      </form>
    );
  }
}

비제어 컴포넌트는 DOM이 갖고 있는 값을 기준으로 상태를 통일한다. 다르게 말하면 input의 값이 필요할 때 DOM으로부터 꺼낸다고 표현할 수 있다. 이러한 특징 때문에 비제어 컴포넌트를 활용하면 더 쉽게 React 코드와 React가 아닌 코드를 결합 할 수 있다.

그래서 무엇을 사용하면 될까?

React의 공식문서에서는 대부분의 상황에서는 제어 컴포넌트를 사용할 것을 권장한다.

In most cases, we recommend using controlled components to implement forms. In a controlled component, form data is handled by a React component. The alternative is uncontrolled components, where form data is handled by the DOM itself.

하지만 제어컴포넌트를 통해 여러 개의 input을 동시에 처리 하는 경우, 불필요하고 잦은 리렌더링이 발생한다는 문제점이 있다. 이 문제를 해결하기 위해 사용자의 모든 상화작용에 반응하지 않는 방법을 고려할 수 있다(onChange를 사용하지 않는 방식). 하지만, 이러한 경우에는 사용자에게 즉각적인 피드백을 보여주기가 힘들다.

이 문제는 제어 컴포넌트와 비제어 컴포넌트를 혼합해서 사용하는 방법으로 해결할 수 있다.

/**
 * 우리는 이 컴포넌트에 많은 것을 넘겨줄 필요가 없습니다.
 * `name`은 폼이 제출되었을 때 form.elements에서 필드값을 찾는데 사용되기 때문에 중요합니다.
 * wasSubmitted는 이 필드가 터치되지 않았더라도 에러 메시지를 표시해야 하는지를 판단하는데 유용합니다.
 * 다른 모든 것들은 내부적으로 관리되므로 이 필드는 SlowInput 컴포넌트와 같은 불필요한 리렌더링을 경험하지 않습니다.
 */
function FastInput({ name, wasSubmitted }: { name: string; wasSubmitted: boolean }) {
  const [value, setValue] = React.useState('');
  const [touched, setTouched] = React.useState(false);
  const errorMessage = getFieldError(value);
  const displayErrorMessage = (wasSubmitted || touched) && errorMessage;

  return (
    <div key={name}>
      <PenaltyComp />
      <label htmlFor={`${name}-input`}>{name}:</label> <input
        id={`${name}-input`}
        name={name}
        type="text"
        onChange={(event) => setValue(event.currentTarget.value)}
        onBlur={() => setTouched(true)}
        pattern="[a-z]{3,10}"
        required
        aria-describedby={displayErrorMessage ? `${name}-error` : undefined}
      />
      {displayErrorMessage ? (
        <span role="alert" id={`${name}-error`} className="error-message">
          {errorMessage}
        </span>
      ) : null}
    </div>
  );
}

/**
 * FastForm 컴포넌트는 비제어 방식을 사용합니다.
 * 모든 값을 추적하고 각 필드에 전달하는 대신 필드 자체에서 값을 추적하게 하고
 * 제출될 때 form.elements에서 값을 찾습니다.
 */
function FastForm() {
  const [wasSubmitted, setWasSubmitted] = React.useState(false);

  function handleSubmit(event: React.FormEvent<HTMLFormElement>) {
    event.preventDefault();
    const formData = new FormData(event.currentTarget);
    const fieldValues = Object.fromEntries(formData.entries());

    const formIsValid = Object.values(fieldValues).every((value: string) => !getFieldError(value));

    setWasSubmitted(true);
    if (formIsValid) {
      console.log(`Fast Form Submitted`, fieldValues);
    }
  }

  return (
    <form noValidate onSubmit={handleSubmit}>
      {fieldNames.map((name) => (
        <FastInput key={name} name={name} wasSubmitted={wasSubmitted} />
      ))}
      <button type="submit">Submit</button>
    </form>
  );
}

FastInput는 하나의 input을 관리한다. FastInput 컴포넌트, 즉 자식 컴포넌트에서 상태를 제어 컴포넌트 형식으로 관리한다. 덕분에 우리는 사용자에게 즉각적인 피드백을 제공하는 동시에, 불필요한 렌더링을 줄일 수 있다. 그렇다면 어떻게 여러개의 input data를 취합해서 관리할 수 있을까?

FastFormFastInput들의 부모 컴포넌트이다. 또한 FastForm에서 비제어 컴포넌트 형식으로 input 데이터를 취합해서 관리한다. 이 때, ref를 사용하는게 아니라 submit event로부터 데이터를 넘겨받아 조작할 수 있다.

function handleSubmit(event: React.FormEvent<HTMLFormElement>) {
  event.preventDefault();
  const formData = new FormData(event.currentTarget);
  const fieldValues = Object.fromEntries(formData.entries());
  ...
}

이러한 방식을 통해 여러 input을 다룰 때, 불필요한 리렌더링을 줄이는 동시에 사용자에게도 즉각적인 변화 피드백을 제공할 수 있다.

References

0개의 댓글