본문 바로가기
Front-End/React

[React] Controlled vs Uncontrolled component

by SeanK 2022. 6. 21.

리엑트를 이용해 어플을 제작하다 보면 심심찮게 아래와 같은 에러 코드를 만날 수 있다. 

 

Elements should not switch from uncontrolled to controlled (or vice versa).

 

이 에러 코드는 Input Textarea Selct 등과 같이 몇몇 태그를 사용할 때 빈번히 나타난다. 

 

관련해서 잘 설명된 글이 있어 번역해 옮겨본다. 

 

To Be or Not to Be

리엑트에서 controlled와 uncontrolled 컴포넌트 선택하기

 

Controlled와 uncontrolled 컴포넌트는 리엑트에서 중요한 개념이다. 어떠한 경우에 어울리는지와 그 차이점을 아는 것은 매우 중요하다. 

 

리엑트에서 controlled와 uncontrolled란 무엇일까?

  • controlled 컴포넌트란 자식의 데이터를 관리하는 컴포넌트를 의미한다.
  • uncontrolled 컴포넌트란 자식이 자신의 데이터를 관리하도록 하는 컴포넌트를 뜻한다. 

여기서 자식이라 함은 보통은 HTML의 형식 엘리먼트, 즉 <input> <textarea> <select> 등을 말한다. 이 포스팅에서는 <input type='text'>를 예제로 이용해 볼 것이다. 

 

자식은 리엑트 컴포넌트가 될 수 도 있다. 

 

Uncontrolled 컴포넌트는 자연스러운 HTML 방식을 사용한다. DOM이 값을 보유하고 업데이트하는 방식이다. 아래 App은 uncontrolled 컴포넌트이며 입력값은 HTML 엘리먼트 input에 의해 유지된다. 

const App = () => {
  return (
    <form>
      <input type="text"/>
    </form>
  );
}

Controlled 컴포넌트는 리엑트의 방식대로 호출되며 리엑트 팀에서는 이 방식을 추천한다. 

 

Controlled input은 props를 통해 값을 전달받고, 값을 업데이트하는 콜백 함수를 제공한다. 이 값은 보통 컴포넌트의 스테이트로 관리된다. 사용법은 간단하다. 

const App = () => {
  const [value, setValue] = React.useState('');
  return (
    <form>
      <input type="text" value={value} onChange={e=>setValue(e.target.value)}/>
    </form>
  );
}

controlled와 uncontrolled 컴포넌트의 차이점을 예제를 통해 알아보자. 

 


Example 1: Convert Each Input Keystroke to Uppercase

Uncontrolled 컴포넌트를 어떻게 관리하면 될까? 리엑트는 변화를 감지하는 이벤트 핸들러를 제공하고 이를 이용해 변화에 대응하는 콜백 함수를 사용한다. 

 

아래의 경우, onChange 핸들러는 uncontrolled input 값을 대문자로 변화시킨다. 이 이벤트는 매 입력값마다 발생한다. 

const App = () => {
  const handleChange = e => {
    e.target.value = e.target.value.toUpperCase();
  }
  return (
    <form>
      <input type="text" onChange={handleChange}/>
    </form>
  );
}

이는 controlled 컴포넌트로는 아래와 같이 만들어 볼 수 있다:

const App = () => {
  const [textInput, setTextInput] = React.useState('');
  const handleChange = (e) => {
    setTextInput(e.target.value.toUpperCase());
  }
  return (
    <form>
      <input type="text" value={textInput} onChange={handleChange}/> 
    </form>
  );
}

Example 2: Convert Each Input Keystroke to Uppercase With an Initial Value

예제를 한번 변형해 초기값을 설정해보자. 아래는 uncontrolled 컴포넌트가 초기값을 다루는 방법이다. 

const App = () => {
  const handleChange = e => {
    e.target.value = e.target.value.toUpperCase();
  }
  return (
    <form>
      <input type="text" defaultValue="START" onChange={handleChange}/>
    </form>
  );
}

아래는 controlled 컴포넌트가 초기값을 다루는 방법이다.

const App = () => {
  const [textInput, setTextInput] = React.useState('START');
  const handleChange = (e) => {
    setTextInput(e.target.value.toUpperCase());
  }
  return (
    <form>
      <input type="text" value={textInput} onChange={handleChange}/> 
    </form>
  );
}

Example 3: Convert Input to Uppercase After Clicking a Button

이번 예제에서는 외부 이벤트에 의한 input 엘리먼트 관리를 해보도록 하겠다. 대문자 변형이 버튼 클릭이 될때까지 딜레이 되는 예제이다.

 

여기서 문제가 하나 발생한다. 버튼의 이벤트 핸들러에서 uncontrolled 인풋 필드에 접근을 할 수 없다. 

const App = () => {
  const handleClick = e => {
    ??
  };
  return (
    <form>
      <input type="text"/>
      <button onClick={handleClick}>Convert to uppercase</button>    
    </form>
  );
}

리엑트에서는 이러한 문제를 해결하기 위해 refs를 제공한다. ref란 DOM 엘리먼트나 리엑트 엘리먼트에 접근하는 한 방법이다. 

 

아래 코드에서, React.useRef()는 textInput이라는 ref를 생성한다. 해당 ref는 input 엘리먼트에 전달되고 마운트 될 때 textInput.current가 이 input 엘리먼트를 가리키게 된다. 

 

그러면 textInput.current를 이용해 uncontrolled 인풋 값을 다룰 수 있게 된다. 

const App = () => {
  const textInput = React.useRef();
  const handleClick = e => {
    textInput.current.value = textInput.current.value.toUpperCase();
  };
  return (
    <form>
      <input type="text" ref={textInput}/>
      <button onClick={handleClick}>Convert to uppercase</button>    
    </form>
  );
}

아래는 controlled 컴포넌트의 예제이다:

const App = () => {
  const [textInput, setTextInput] = React.useState('');
  const handleChange = (e) => {
    setTextInput(e.target.value);
  }
  const handleClick = () => {
    setTextInput(textInput.toUpperCase());
  }
  return (
    <form>
      <input type="text" value={textInput} onChange={handleChange}/>
      <button onClick={handleClick}>Convert to uppercase</button>  
    </form>
  );
}

Example 4: Convert Valid Input to Uppercase

타당성 체크를 예시에 추가해 보도록 하겠다. 아래 예시에서는 문자만이 올바른 인풋이다. 만약 올바르지 않은 키가 입력되면, 이는 무시되고 빨간 메시지가 표시될 것이다. 

 

uncontrolled 컴포넌트에서 제너릭한 onChange 이벤트 핸들러를 onKeyPress로 바꾸었다. 왜냐하면 onChange가 작동하기 전에 기본 설정 동작을 막아야 할 필요가 있기 때문이다. 

const App = () => {
  const [isError, setError] = React.useState(false);
  const handleChange = e => {
    if (/^[a-zA-Z]+$/.test(e.key)) {
      setError(false);
    } else {
      setError(true);
      e.preventDefault();
    }
  };
  return (
    <form>
      <input type="text" onKeyPress={handleChange}/>
      {isError && <div style={{color: "red"}}>The input only accepts letters</div>}   
    </form>
  );
}

controlled 컴포넌트에선 이벤트 핸들러를 바꿀 필요가 없다. 그리고 input 엘리먼트의 기본 설정 동작을 방지할 필요도 없다. 

const App = () => {
  const [textInput, setTextInput] = React.useState('');
  const [isError, setError] = React.useState(false);
  const handleChange = (e) => {
    if (/^[a-zA-Z]+$/.test(e.target.value)) {
      setTextInput(e.target.value);
      setError(false);
    } else {
      setError(true);
    }
  }
  return (
    <form>
      <input type="text" value={textInput} onChange={handleChange}/>
      {isError && <div style={{color: "red"}}>The input only accepts letters</div>}
    </form>
  );
}

Example 5: Form Submission Use Case

아래는 form submission 사용 케이스다:

  • 이름과 이메일 두개의 인풋 필드가 있다. 이름은 필수 필드이고 이메일은 정규 표현 테스트를 통과해야 한다.
  • 만약에 타당성 에러가 발생하면 첫번째 에러가 빨간색으로 표시된다. 
  • 제출 버튼은 모든 값이 타당할 때에만 사용 가능해진다. 
  • 양식 제출 즉시 보냄 표시가 표여진다. 이 메시지는 유저가 새로운 값을 입력할 때까지 남아있는다. 

아래 코드는 uncontrolled 컴포넌트 사용 예제이다. 

const App = () => {
  const [error, setError] = React.useState('');
  const [submission, setSubmission] = React.useState('');
  const form = React.useRef();
  const handleChange = () => {
    const { name, email, submit } = form.current;
    if (!name.value) {
      setError('Name is required');
      submit.disabled = true;
    } else if (!/^\w+([\.-]?\w+)*@\w+([\.-]?\w+)*(\.\w{2,3})+$/.test(email.value)) {
      setError('Invalid email');
      submit.disabled = true;
    } else {
      setError('');
      submit.disabled = false;
    }
    setSubmission('');
  };
  const handleSubmit = e => {
    e.preventDefault();
    const { name, email, submit } = form.current;
    if (!error) {
      setSubmission(`Sent info - name: ${name.value}, email: ${email.value}`)
      submit.disabled = true;
      e.target.reset();
    }
  };
  return (
    <form ref={form} onSubmit={handleSubmit}>
      <input name="name" onChange={handleChange}/>
      <input name="email" onChange={handleChange}/>
      <div>{submission}</div>
      <button name="submit" disabled type="submit">Submit</button>
    </form>
  );
}

그리고 아래는 동일한 동작을 하는 controlled 컴포넌트 코드이다. 

const App = () => {
  const [name, setName] = React.useState('');
  const [email, setEmail] = React.useState('');
  const [isDisabled, setIsDisabled] = React.useState(true);
  const [error, setError] = React.useState('');
  const [submission, setSubmission] = React.useState('');
  const handleChange = e => {
    if (e.target.name === 'name') {
      setName(e.target.value);
    } else if (e.target.name === 'email') {
      setEmail(e.target.value);
    }
    setSubmission('');
  };
  const handleSubmit = e => {
    e.preventDefault();
    if (!error) {
      setName('');
      setEmail('');
      setSubmission(`Sent info - name: ${name.value}, email: ${email.value}`)
      setIsDisabled(true);
    }
  };
  React.useEffect(() => {
    if (!submission) {
      if (!name) {
        setError('Name is required');
        setIsDisabled(true);
      } else if (!/^\w+([\.-]?\w+)*@\w+([\.-]?\w+)*(\.\w{2,3})+$/.test(email)) {
        setError('Invalid email');
        setIsDisabled(true);
      } else {
        setError('');
        setIsDisabled(false);
      }
    }
  }, [submission, name, email]);  
return (
    <form onSubmit={handleSubmit}>
      <input name="name" value={name} onChange={handleChange}/>
      <input name="email" value={email} onChange={handleChange}/>
      <div style={{color: "red"}}>{error}</div>
      <div>{submission}</div>
      <button name="submit" disabled={isDisabled} type="submit">Submit</button>
    </form>
  )
}

Example 6: Component With React Children

서두에서 controlled와 uncontrolled를 구분하는 메서드가 React 컴포넌트의 자식 컴포넌트에도 적용될 수 있다고 언급했다. 

 

아래 예문에서 App은 uncontrolled 컴포넌드이지만 자식 컴포넌트 ShowUpperCase는 controlled 컴포넌트이다. 

const ShowUpperCase = () => {
  const [textInput, setTextInput] = React.useState('');
    const handleChange = (e) => {
      setTextInput(e.target.value.toUpperCase());
    }
    return (
      <input type="text" value={textInput} onChange={handleChange}/> 
  );
}
const App = () => {
  return (
    <form>
      <ShowUpperCase/>  
    </form>
  );
}

동일한 동작을 controlled 방식으로 구현 가능하다. 아래 App은 controlled 컴포넌트이고 ShowUpperCase의 데이터를 관리한다. ShowUpperCase는 마찬가지로 Controlled 컴포넌트다.

const ShowUpperCase = (props) => {
  return (
    <input type="text" value={props.value.toUpperCase()} onChange={props.onChange}/> 
  );
}
const App = () => {
  const [textInput, setTextInput] = React.useState('');
  const handleChange = (e) => {
    setTextInput(e.target.value.toUpperCase());
  }
  return (
    <form>
      <ShowUpperCase value={textInput} onChange={handleChange}/> 
    </form>
  );
}

결론

이상 controlled와 uncontrolled 컴포넌트 예제를 살펴보았다. 모두 동작은 같으니 케이스에 따라 사용하면 된다. 

 

하지만 특정 컴포넌트에는 한가지 방법을 따르는 것이 좋다. 동일한 컴포넌트에 controlled와 uncontrolled를 섞지 마라. 그리고 컴포넌트의 라이프타임 동안에는 타입을 변경하지 말라. 

 

때때로, 아래와 같은 경고 콘솔을 만날 수 있다:

A component is changing an uncontrolled input of type checkbox to be controlled. Input elements should not switch from uncontrolled to controlled (or vice versa). 

 

이 경고문구는 의도치않게 state를 null 혹은 undefined로 설정해 발생한 것일 수 있다. 리엑트에서는 이를 uncontrolled로 인식한다. 값을 초기화할 때는 값을 ''혹은 false로 설정하라. 

 

그렇다면 이제 한번 문제를 내보겠다!

 

아래 코드에서 App은 controlled일까 아니면 uncontrolled일까?

const App = () => {
  const [textInput, setTextInput] = React.useState('');
    const handleChange = (e) => {
      setTextInput();
    }
    return (
      <input type="text" value={textInput} onChange={handleChange}/> 
  );
}

정답은 uncontrolled이다.

 

App은 controlled 컴포넌트와 같이 작성되었지만 undefined 값이 인풋 엘리먼트를 uncontrolled로 바꾸어 버린다!

 

 

 

 

출처:https://betterprogramming.pub/to-be-or-not-to-be-2c372198a01c

 

To Be or Not to Be

Choosing between controlled and uncontrolled components in React

betterprogramming.pub