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해주는 똑같은 코드가 두 번 반복되고 있습니다.
(상위 컴포넌트에서 한 번, 컴포넌트 내에서 한 번)
생각해 보면, 내부에서 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
문까지 써야 합니다.
이러한 구조는 정말 좋지 않다고 단언할 수 있습니다. 이런 식으로 코드를 짜야 할 것 같은 때가 있다면, 더 나은 방법이 정말로 없는지 다시 한 번 생각해봐야 합니다.