본문 바로가기

FrontEnd

리액트 디자인 패턴 : uncontrolled component pattern

반응형

TL;DR

uncontrolled component는 컴포넌트의 관심사를 달성하기 위해 스스로 내부 상태를 관리하는 컴포넌트입니다.


 

컴포넌트에 상태 관리 책임을 넘기고 편해지는 방법을 알아봅시다.

원문 번역입니다 : https://jjenzz.com/component-control-freak

 

Are You a Component Control Freak?

It's tempting to always control the components we implement but we can sometimes simplify things if we use the uncontrolled pattern.

jjenzz.com

우리는 종종 컴포넌트의 상태를 직접 제어하려 합니다.

리액트 튜토리얼(React’s own intro tutorial)을 포함하여, 상태 관리 튜토리얼이 도처에 널려있기 때문에,

상태 관리를 반드시 해야 한다는 강박에 빠지기 쉽습니다.

제어되는 컴포넌트(controlled component)는 onPropChange 콜백과 값 prop 쌍을 필요로 합니다.
컴포넌트 내 값의 표현은 이 두 prop에 의해 완전히 제어됩니다.
  • 예를 들어, input에는 value와 onChange prop이 있습니다.
두 prop이 제공되면 input이 사용자에 의해 제어되며
onChange 이벤트 발생 시 업데이트된 값을 다시 input에 전달하는 것은 사용자의 책임입니다.

Uncontrolled components

Uncontrolled components는 상태 관리를 스스로 수행하는 컴포넌트 입니다.
꼭 필요할 때애만 사용자가 초기 상태를 지정하는 것을 허용합니다.
 
uncontrolled component는 공식 문서에서 매우 간략하게 다루며(briefly in the docs) input 및 ref에만 중점을 둡니다.
좋은 글이지만 패턴의 잠재력을 완전히 이해하기엔 부족합니다.
우리는 스스로 uncontrolled 컴포넌트를 개발할 수 있습니다!
 
uncontrolled component는 컴포넌트의 관심사를 달성하기 위해 스스로 내부 상태를 관리하는 컴포넌트입니다.
uncontrolled component는 소비자가 초기 상태를 지정할 수 있도록 허용하며
이는 일반적으로 default 접두사가 붙은 prop에 의해 선언됩니다.(defaultProps)
uncontrolled inputs 문서는 defaultValue 또는 defaultChecked prop을 사용하는 예를 보여줍니다.

Uncontrolled Component 만들기

다음은 dumb(stateless) 컴포넌트가 자주 빌드되는 방법입니다.

몇몇 prop과 callback을 제공하면 dumb Counter 컴포넌트를 얻을 수 있습니다.

 
const Counter = ({ count = 0, onChange }) => {
  // Effect with `count` dependency so effect runs every time `count` changes
  React.useEffect(() => {
    setTimeout(() => onChange(count + 1), 1000);
  }, [count]);

  return <span>{count}</span>;
};​
해당 컴포넌트는 상태가 없으므로, 상위 컴포넌트에서 prop으로 넘겨줍니다.
const App = () => {
  const [countOneValue, setCountOneValue] = React.useState(0);
  const [countTwoValue, setCountTwoValue] = React.useState(10);

  return (
    <div>
      <p>
        <Counter count={countOneValue} onChange={setCountOneValue} />
      </p>
      <p>
        <Counter count={countTwoValue} onChange={setCountTwoValue} />
      </p>
    </div>
  );
};

예상대로 동작하지만 두 개의 제어되는 카운터 상태가 필요합니다.
Counter 컴포넌트를 사용하려는 사람은 모두 유사한 상태를 구현해야 합니다.

 

동일한 리프팅 상태를 반복하는 이러한 상황에 처했을 때 uncontrolled 컴포넌트로 변경하는 것이 도움이 됩니다.

즉, 부모에서 상태를 Counter로 이동하여 컴포넌트 스스로 상태를 관리하도록 합니다.

const App = () => (
  <div>
    <p>
      <Counter />
    </p>
    <p>
      <Counter defaultCount={10} />
    </p>
  </div>
);

const Counter = ({ defaultCount = 0 }) => {
  const [count, setCount] = React.useState(defaultCount);

  React.useEffect(() => {
    setTimeout(() => setCount(prevCount => prevCount + 1), 1000);
  }, [count]);

  return <span>{countValue}</span>;
};

자체적으로 상태를 관리하지만, 예상대로 동작하는 매우 간단한 uncontrolled component를 얻었습니다.
API 컨슈머는 기대되는 동작을 위해 상태를 더 이상 제어할 필요가 없으며

결과적으로 상위 컴포넌트의 상용구를 제거할 수 있었습니다.


uncontrolled component control하기

클라이언트는 결국 애매한 요구 사항을 제시하게 됩니다.
그리고 요구사항의 범위가 넓어집니다.
  • 버튼을 클릭하면 10씩 증가하길 원할 수 있습니다.
    • 문제 없습니다. step prop을 추가할 수 있습니다.
  • 갑자기 2의 배수로 증가하기를 원합니다.
즉, 소비자는 카운터 계산 방식에 대해 더 많은 제어를 필요로 합니다.
다행히도 우리는 그들에게 그 통제의 선택권을 줄 수 있습니다.
 
컴포넌트는 소비자가 제어 버전의 prop(이 경우에는 count prop)을 전달했는지 확인하여
제어 및 제어되지 않은 구현을 모두 지원할 수 있습니다.
제어를 위한 props가 있다면, 해당 props를 사용하여 렌더링을 제어하도록 전환할 수 있습니다.
const Counter = ({
  count: countProp,
  defaultCount = 0,
  onChange = () => {},
}) => {
  // local state for uncontrolled version
  const [countState, setCountState] = React.useState(defaultCount);

  // whether consumer is trying to control it or not. we use a ref because
  // components should not switch between controlled/uncontrolled at runtime
  const isControlled = React.useRef(countProp !== undefined).current;

  // the count value we render depending on whether it is controlled
  const count = isControlled ? countProp : countState;

  // maintaining change callback in a ref (more on that later)
  const handleChangeRef = React.useRef(onChange);
  React.useLayoutEffect(() => {
    handleChangeRef.current = onChange;
  });

  React.useEffect(() => {
    const handleChange = handleChangeRef.current;

    setTimeout(() => {
      if (isControlled) {
        handleChange(count + 1);
      } else {
        setCountState((prevCount = 0) => {
          const nextCount = prevCount + 1;
          handleChange(nextCount);
          return nextCount;
        });
      }
    }, 1000);
  }, [isControlled, count]);

  return <span>{count}</span>;
};
 
handleChangeRef를 유지해야 하기 때문에 복잡해 보입니다.
이전에는 effect 종속성이 count 뿐이었으므로 count가 변경될 때만 실행되기 때문에 이것이 필요하지 않았습니다.
그러나 이 새로운 effect는 onChange 콜백에도 의존해야 합니다.
이를 직접 의존성에 추가하면 핸들러를 인라인 함수로 전달할 시, 해당 핸들러가 모든 렌더링 마다 호출횔 것입니다.

이 접근 방식은 효과 종속성으로 추가하지 않고 핸들러에 대한 업데이트된 참조를 제공하므로,
카운트가 변경될 때만 효과가 실행되도록 합니다.

 

(업데이트: React 팀은 이후 이 사용 사례에 대한 useEvent 훅에 대한 RFC(an RFC for a useEvent)를 발표했습니다)
(역자의 업데이트 : https://github.com/reactjs/rfcs/pull/229 때문에 위 제안이 폐기될 수도 있다고 들었습니다.)
때로는 사용자 상호 작용에 대한 응답으로만 해당 onChange가 호출되므로 이 effect 관리가 필요하지 않습니다.
컴포넌트의 제어되지 않는/제어되는 버전을 지원하는 데 필요한 중요한 부분을 아래에 강조 표시했습니다.
const Counter = ({
  count: countProp,
  defaultCount = 0,
  onChange = () => {},
}) => {
  const [countState, setCountState] = React.useState(defaultCount);
  const isControlled = React.useRef(countProp !== undefined).current;
  const count = isControlled ? countProp : countState;

  // ...
  if (isControlled) {
    onChange(count + 1);
  } else {
    // if component is uncontrolled, we set the internal state
    setCountState((prevCount = 0) => {
      const nextCount = prevCount + 1;
      onChange(nextCount);
      return nextCount;
    });
  }

  // ...
  return <span>{count}</span>;
};​

isControlled prop의 정의 여부를 확인하는 isControlled 불리언을 유지합니다.
그런 다음 isControlled 불리언을 사용하여 컴포넌트를 내부 상태 관리(제어되지 않음)에서 props(제어됨) 사용으로 전환합니다.

즉 우리는, React form의 필드와 유사한 것을 만들었습니다.
제어되거나 제어되지 않을 수 있는 컴포넌트입니다.


Uncontrolled forms

아래는 form의 일반적인 사용 방식입니다.
const Contact = () => {
  const [name, setName] = React.useState(‘’);
  const [email, setEmail] = React.useState(‘’);
  const [message, setMessage] = React.useState(‘’);
  const [isSubscribed, setIsSubscribed] = React.useState(false);

  function handleSubmit(event) {
    fetch(‘/contact’, {
      mode: ‘POST’,
      body: JSON.stringify({ name, email, message, isSubscribed }),
    });

    event.preventDefault();
  }

  return (
    <form onSubmit={handleSubmit}>
      <label>
        Name
        <input type=“text” value={name} onChange={event => setName(event.target.value)} />
      </label>
      <label>
        Email
        <input type=“email” value={email} onChange={event => setEmail(event.target.value)} />
      </label>
      <label>
        Message
        <textarea value={message} onChange={event => setMessage(event.target.value)} />
      </label>
      <label>
        <input type=“checkbox” checked={isSubscribed} onChange={event => setIsSubscribed(event.checked)} />
        Please check this box if you would like to subscribe to our newsletter
      </label>
    </form>
  );
}
form 필드의 값을 이용해 JSON 문자열을 만들 수 있도록 입력 값을 사용자가 직접 관리하고 있습니다.
사용자가 필드를 업데이트할 때마다 전체 form이 다시 렌더링됩니다.
form은 본질적으로 데이터 저장소입니다.
즉, 제어되지 않은 상태로 개발하기에 완벽한 후보입니다.
input은 제어되지 않을 수 있으므로 사용자가 제어할 필요 없이 입력한 내용을 유지합니다.
form에 name을 지정하면 FormData API를 사용하여 필요할 때 form의 값을 가져올 수 있습니다.
const Contact = () => {
  function handleSubmit(event) {
    const formData = new FormData(event.currentTarget);
    const body = Object.fromEntries(formData.entries());

    fetch(‘/contact’, { mode: ‘POST’, body: JSON.stringify(body) });
    event.preventDefault();
  }

  return (
    <form onSubmit={handleSubmit}>
      <label>
        Name
        <input type=“text” name="name" />
      </label>
      <label>
        Email
        <input type=“email” name="email" />
      </label>
      <label>
        Message
        <textarea name="message" />
      </label>
      <label>
        <input type=“checkbox” name="isSubscribed" />
        Please check this box if you would like to subscribe to our newsletter
      </label>
    </form>
  );
}​
크게 코드 양이 줄진 않았지만, 다음과 같은 효과를 얻었습니다.
  • 불필요한 상태 관리를 제거하여 컴포넌트를 단순화했습니다.
  • 모든 키 입력이 전체 form을 렌더링하는 것을 방지하였습니다.

위는 매우 일반적입니다.
onSubmit 및 onChange 이벤트가 실행될 때, 필드의 객체 리터럴 표현을 제공하는 유틸 함수와 함께 Form 컴포넌트를 만듭니다


Uncontrolled 컴포넌트와 key prop

또 다른 강력한 방법은 uncontrolled 컴포넌트를 React의 key prop과 결합하는 것입니다.
 
우리는 종종 한 단계 또는 다른 단계의 특정 조건에서 상태를 재설정하기 위해 컴포넌트에 로직을 추가했습니다.
다음 예제는 사용자가 라디오 목록에서 다른 게시물을 선택할 때 댓글 form을 재설정합니다.
const DEFAULT_COMMENT = 'Sounds decent';

const App = () => {
  const [selectedPostId, setSelectedPostId] = React.useState();
  const [comment, setComment] = React.useState(DEFAULT_COMMENT);

  React.useEffect(() => {
    // reset state back to original
    setComment(DEFAULT_COMMENT);
  }, [selectedPostId]);

  function handleSubmitComment(event) {
    submitComment(comment);
    event.preventDefault();
  }

  return (
    <div>
      <ul>
        {['1', '2', '3'].map(postId => (
          <li key={postId}>
            <input
              type="radio"
              value={postId}
              onChange={event => setSelectedPostId(event.target.value)}
              checked={selectedPostId === postId}
            />{' '}
            Post {postId}
          </li>
        ))}
      </ul>
      {selectedPostId && (
        <form onSubmit={handleSubmitComment}>
          <h2>Comment on post {selectedPostId}</h2>
          <textarea
            value={comment}
            onChange={event => setComment(event.target.value)}
          />
          <br />
          <button>comment</button>
        </form>
      )}
    </div>
  );
};
comment 상자에 텍스트를 추가한 다음 다른 게시물을 선택하면 텍스트가 기본값으로 다시 재설정되는 것을 볼 수 있습니다.
이와 같이 상태를 재설정하는 코드 대신 key prop을 사용할 수 있습니다.

키 prop이 변경되면 컴포넌트를 다시 인스턴스화합니다. 즉 상태를 초기화 합니다.
 이를 활용하여 comment form을 원래 상태로 되돌릴 수 있습니다.

const App = () => {
  const [selectedPostId, setSelectedPostId] = React.useState();

  return (
    <div>
      <ul>
        {['1', '2', '3'].map(postId => (
          <li key={postId}>
            <input
              type="radio"
              value={postId}
              onChange={event => setSelectedPostId(event.target.value)}
              checked={selectedPostId === postId}
            />{' '}
            Post {postId}
          </li>
        ))}
      </ul>
      {selectedPostId && <Comment key={selectedPostId} id={selectedPostId} />}
    </div>
  );
};

const Comment = ({ id }) => {
  function handleSubmitComment(event) {
    const formData = new FormData(event.currentTarget);
    submitComment(formData.get('comment'));
    event.preventDefault();
  }

  return (
    <form onSubmit={handleSubmitComment}>
      <h2>Comment on post {id}</h2>
      <textarea name="comment" defaultValue="Sounds decent" />
      <br />
      <button>comment</button>
    </form>
  );
};
초기 값을 defaultValue Prop으로 전달하여 텍스트 영역이 제어되지 않도록 한 다음
key prop을 사용하여 선택한 Post가 변경되면 Comment 컴포넌트를 다시 인스턴스화 하도록 합니다.
그러면 텍스트 영역의 텍스트가 재설정됩니다.
동작은 여기(see it in action)에서 확인하세요
 
effect, 기본 상태 값 또는 coment 상태에 대한 추상화된 변수가 더 이상 필요하지 않은 점에 주목하세요.
모든 것이 독립적이고 명시적입니다.
이 방법은 React 팀이 직접 제안한 것입니다.(React team suggest it)

이것으로 꽤 멋진 일을 할 수 있습니다.
optimistic update craziness 링크를 확인해 보세요.
 
uncontrolled component는 시도된 상태 변경을 즉시 반영하기 때문에 "낙관적인 컴포넌트(optimistic component)"라고 생각할 수 있습니다.
따라서 컴포넌트의 상태를 직접 제어하며 "이전 값" 상태를 유지하고, 요청이 실패할 때 해당 값으로 재설정하는 대신,
요청이 실패할 경우 컴포넌트를 다시 인스턴스화하는, uncontrolled with key prop 컴포넌트를 사용할 수 있습니다. 
 
역자 주 : 요청 시마다
  1. input은 uncontrolled 값을 사용해 보여주고
  2. 내부적으로 이전값 역할을 할 상태와 에러 플래그를 이용해 리렌더링 할 때,
    1. 요청 실패 시 key만 바꿔주도록 하면
  3. 리렌더링 시 이전값 역할을 하는 내부 상태를 defaultValue로 리렌더링

결론

상단 컴포넌트로 상태를 리프팅 하기 전에 생각해봅시다.
  • 이 상태가 정말 필요한가요?
  • 제어되지 않는(uncontrolledl) API를 사용하면 복잡성을 줄일 수 있나요?
  • 컴포넌트를 초기화 하려는 경우 uncontrolled/key prop 페어링이 더 가벼운 컴포넌트에 도움이 될 수 있습니다.
    • 초기 상태를 복사해서 유지하지 마세요!

저는 이런 식으로 생각함으로써 응용 프로그램의 복잡성을 줄이는 데 성공했습니다.
여러분도 유용하게 사용하시길 바랍니다 🙂

반응형