예시로 보는 HTML input 엘리먼트를 올바르게 래핑하기

Osol2·2022년 11월 11일
0
post-thumbnail
post-custom-banner

HTML input element를 래핑하여 만든 컴포넌트를 볼 때, 좋지 않은 방식으로 컴포넌트를 작성하는 경우를 많이 보았습니다.
이 글에서는 예시를 통해 잘못된 코드들을 살펴보고, 어떻게 고칠 수 있는지 단계적으로 살펴보겠습니다.

아래와 같이, <textarea> 엘리먼트가 있고, 그 안의 텍스트 길이가 실시간으로 보이는 컴포넌트를 만드는 상황을 가정해 봅시다.

여러분도 이러한 컴포넌트를 만든다면 어떻게 만들 것인지 머릿속으로 생각해보세요.

저는 아래와 같이 <textarea>를 extend하는 식으로 작성해 보았습니다.
Codesandbox

interface Props
  extends Omit<React.ComponentPropsWithoutRef<"textarea">, "value"> {
  value?: string;
}

const TextboxWithLength = ({ value, ...rest }: Props) => {
  return (
    <div id="Textbox">
      <textarea value={value} {...rest} />
      <p>
        길이: <strong>{value?.length ?? 0}</strong>
      </p>
    </div>
  );
};

하지만 아래와 같이 텍스트를 입력해도 길이가 계속 0으로 나오고 있습니다.

텍스트의 길이를 표시하기 위해 value.length 라는 값을 가져오려고 하고 있으나, <textarea> 내부의 텍스트가 변수 value에 반영되지 않고 있기 때문에, <textarea>에 텍스트를 입력해도 value는 그대로 빈 스트링이므로 길이가 0에서 변하지 않는 것입니다.
그렇다면, <textarea>안의 값을 value로 반영하면 될 테니, 흔히 text input을 사용할 때처럼 useState 훅으로 value라는 상태값을 만들어 컴포넌트에 전달하도록 구현하면 작동하면 될 것 같습니다.
이러한 가정 하에 구현한 코드가 두 번째 ‘작동하는 예시’ 코드입니다.

const [text, setText] = useState("");

return (
  <div className="App">
    <b>작동하는 예시</b>
    <TextboxWithLength
      value={text}
      onChange={(e) => setText(e.target.value)}
      placeholder="글을 입력해 보세요. 텍스트 길이가 하단에 표시됩니다."
    />
  </div>
);

그러고 나니 잘 작동하는 것을 확인할 수 있습니다.

잘 되기는 하지만, 이 동작은 뭔가 이상합니다. 만들려고 했던 것은 단순히 <textarea> 안에 텍스트를 입력하면 그 아래에 텍스트의 길이가 표시되는 것이었는데, 상위 컴포넌트에서 값을 내려줘야만 동작한다는 것은 이상합니다.
별 문제가 없다고 생각할 수도 있지만, 상위 컴포넌트에 로직이 묶여 있기 때문에 아토믹한 컴포넌트를 만드는 데 방해요소가 됩니다. 직관성 측면에서도 좋지 않구요.

그렇다면 상위 컴포넌트를 참조하지 않고, 컴포넌트 내부에 상태값을 만들어 그 상태값을 이용하면 되지 않을까요? 다음을 봅시다.

컴포넌트 내부에서 <textarea> 의 value 상태값을 관리

위 가정에 따라 innerValue 라는 상태값을 만들고, <textarea>value가 아닌 innerValue를 바라보도록 수정했습니다.

const TextboxWithLength = ({ value, onChange, ...rest }: Props) => {
  const [innerValue, setInnerValue] = useState("");

  useEffect(() => {
    setInnerValue(value);
  }, [value]);

  return (
    <div id="Textbox">
      <textarea
        value={innerValue}
        onChange={(e) => {
          setInnerValue(e.target.value);
          onChange?.(e);
        }}
        {...rest}
      />
      <p>
        길이: <strong>{innerValue?.length ?? 0}</strong>
      </p>
    </div>
  );
};

또한 useEffect를 이용해 value가 변경되면 innerValue에도 적용되도록 하였습니다.
그러자 ‘작동하는 예시’ 가 잘 되는 것은 물론이고 ‘작동하지 않음’ 도 잘 되는 것을 확인할 수 있습니다.

그럼 된거야?

전혀 아닙니다. 위 코드는 큰 문제가 있습니다.
innerValue라는 상태값이 하는 일을 보면 딱 하나 뿐입니다. “value가 변경되었을 때, 그 값과 같아지는 것”.
value를 통해 넘어온 값을 단순히 복사해서 <textarea>에 넘겨줄 뿐인 것이죠.
또한, 상태값에 set해주는 똑같은 코드가 두 번 반복되고 있습니다.
(상위 컴포넌트에서 한 번, 컴포넌트 내에서 한 번)

Ref를 활용해 볼까?

생각해 보면, 내부에서 innerValue가 필요했던 이유는 딱 하나, 그 텍스트의 길이를 구하기 위해서였죠.
중복된 상태값을 만들기보다는, 아예 상태값을 사용하지 않고 ref를 통해 DOM 엘리먼트를 직접 참조하여, ref.current.value 와 같이 값을 불러와서 길이를 구하면 간단한 문제가 아닐까요?
그렇게 하면 애초에 상태값을 참조하거나 set을 하지 않기 때문에, 당초의 문제였던 ‘<textarea>의 값이 변경되어도, 상태값에 반영되지 않는다’를 해결할 수 있을 것으로 보입니다.

const TextboxWithLength = ({ value, ...rest }: Props) => {
  const textareaRef = useRef<HTMLTextAreaElement>();

  return (
    <div id="Textbox">
      <textarea value={value} {...rest} ref={textareaRef} />
      <p>
        길이: <strong>{textareaRef.current?.value.length ?? 0}</strong>
      </p>
    </div>
  );
};

하지만, 예상하셨을 수도 있겠지만, 작동하지 않습니다. 왜냐하면 단순히 ref가 참조하는 엘리먼트의 값이 바뀌는 것은 리렌더링을 일으키지 않기 때문입니다.
해결책은 이벤트 리스너를 통해 엘리먼트를 참조하는 것입니다. 값이 변경될 때마다 이벤트 리스너가 호출되기 때문에, 그때마다 ref를 참조하여 길이를 구하면 되겠죠.

완성본

Codesandbox
값이 변경될 때마다 매번 이벤트 리스너에서 엘리먼트를 참조하므로, 잘 작동하는 것을 확인할 수 있습니다.
이런 식으로 작성하면 상위 컴포넌트에 대해 독립적이면서, 불필요한 상태값을 줄일 수 있게 됩니다.
다만 텍스트 길이값을 표시해야 하기 때문에, length 라는 길이값을 저장할 상태값을 만들어 사용하였습니다.
(이벤트 리스너 내에서는 e.target으로 엘리먼트에 접근 가능하기 때문에 ref를 사용하지 않고 두 번째 코드와 같이 더 간결하게 구현할 수 있습니다.)

const TextboxWithLength = ({ value, onChange, ...rest }: Props) => {
  const [length, setLength] = useState<number>(null);
  const ref = useRef<HTMLTextAreaElement>(null);

  return (
    <div id="Textbox">
      <textarea
        value={value}
        ref={ref}
        {...rest}
        onChange={(e) => {
          setLength(ref.current?.value.length);
          onChange?.(e);
        }}
      />
      <p>
        길이: <strong>{length ?? 0}</strong>
      </p>
    </div>
  );
};
const TextboxWithLength = ({ value, onChange, ...rest }: Props) => {
  const [length, setLength] = useState<number>(null);

  return (
    <div id="Textbox">
      <textarea
        value={value}
        {...rest}
        onChange={(e) => {
          setLength(e.target.value.length);
          onChange?.(e);
        }}
      />
      <p>
        길이: <strong>{length ?? 0}</strong>
      </p>
    </div>
  );
};

결론

const TextboxWithLength = ({ value, ...rest }: Props) => {
  return (
    <div id="Textbox">
      <textarea value={value} {...rest} />
      <p>
        길이: <strong>{value?.length ?? 0}</strong>
      </p>
    </div>
  );
};

초입에 있던, 상태값을 넘겨줘야만 작동하는 컴포넌트(위 코드)는 피해야 할 패턴 중 하나라고 생각합니다.
상위 컴포넌트에 (그렇지 않을 수 있는데도)의존적으로 작동하는 컴포넌트는 좋지 않습니다.

const TextboxWithLength = ({ value, onChange, ...rest }: Props) => {
  const [innerValue, setInnerValue] = useState("");

  useEffect(() => {
    setInnerValue(value);
  }, [value]);

  return (
    <div id="Textbox">
      <textarea
        value={innerValue}
        onChange={(e) => {
          setInnerValue(e.target.value);
          onChange?.(e);
        }}
        {...rest}
      />
      <p>
        길이: <strong>{innerValue?.length ?? 0}</strong>
      </p>
    </div>
  );
};

또한, 그 다음의 코드(위 코드)와 같은 패턴도 꽤나 자주 사용되는 것을 목격했습니다.
위에서 언급했다시피, 이는 쓸데없이 복사본을 만들어서 넘겨주는 행동일 뿐입니다. 또한 중복된 코드가 생기고, 심지어 지저분한 useEffect 문까지 써야 합니다.
이러한 구조는 정말 좋지 않다고 단언할 수 있습니다. 이런 식으로 코드를 짜야 할 것 같은 때가 있다면, 더 나은 방법이 정말로 없는지 다시 한 번 생각해봐야 합니다.

profile
프론트엔드 개발자
post-custom-banner

0개의 댓글