사내에서 디자인 시스템 중에는 TextInput
컴포넌트에 “사용자가 텍스트를 입력하면 X 아이콘이 자동으로 보이고, 이 아이콘을 클릭하면 인풋이 비워지는 기능”이 있다.
처음에는 별도의 자바스크립트 로직으로 직접 아이콘을 표시하고, 클릭 이벤트를 연결해 value
를 초기화하는 방안을 떠올렸다.
그러나 제어(Controlled)와 비제어(Uncontrolled) 상황을 모두 고려하다 보니, 분기 처리가 꽤나 복잡해졌다.
예를 들어, 해당 컴포넌트에 내려주는 값을 비제어로 내려주는지, 제어로 내려주는지에 따른 모든 케이스들을 다 고려하여 value는 빈 값으로 변경해줘야하기 때문이다.
그런데 알고 보니 type="search"
라는 HTML 속성만 써도 이 모든 일을 브라우저가 알아서 처리해준다는 사실을 발견했다.
추가 로직 없이도 X 아이콘이 생기고, 클릭하면 인풋 값이 자동으로 비워지니 한결 마음이 편해졌다.
이 글에서는 “기존 JS 로직 버전”과 “
type="search"
버전”을 차례로 살펴보면서,
왜 최종적으로type="search"
가 제어/비제어 인풋 모두에서 더 단순하게 동작하는지 알아본다.
제어 컴포넌트와 비제어 컴포넌트 개념이 익숙하지 않다면, 아래 글을 살펴본 후 이어서 읽기를 권장드린다.
제어 컴포넌트와 비제어 컴포넌트 제대로 이해하기 (feat. React Hook Form)
아래 코드는 “제어와 비제어 상황을 모두 대응해야 한다”는 가정하에 작성된 간단한 예시다.
현실적인 코드에서는 state와 props가 더 다양하고, 조건부 스타일이나 이벤트 핸들러도 늘어나 복잡도가 더 높아질 수 있다.
(이 코드는 완전히 구현된 코드가 아니다. 모든 분기를 처리하면서 커지는 코드의 복잡성을 이해하는 용도로만 참고하길 바란다.)
import React, { useRef, useState, forwardRef, Ref } from 'react';
interface TextInputProps extends React.InputHTMLAttributes<HTMLInputElement> {}
interface MyTextInputProps extends TextInputProps {
controlled?: boolean;
value?: string;
defaultValue?: string;
onChange?: (value: string) => void;
}
const MyTextInput = forwardRef<HTMLInputElement, MyTextInputProps>(({
controlled = false,
value = '',
defaultValue = '',
onChange,
...props
}, ref: Ref<HTMLInputElement>) => {
// 비제어일 경우 내부적으로 관리할 값
const [internalValue, setInternalValue] = useState(defaultValue);
// DOM 접근을 위한 ref
const inputRef = useRef<HTMLInputElement>(null);
const currentValue = controlled ? value : internalValue;
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
if (controlled) {
// 상위에서 제어하는 경우, 상위 콜백 호출
onChange?.(e.target.value);
} else {
// 비제어인 경우 내부 state 갱신
setInternalValue(e.target.value);
}
};
const handleClearClick = () => {
if (controlled) {
onChange?.(''); // 제어 상태에서는 상위로 빈 문자열 전달
} else {
setInternalValue(''); // 비제어 상태에서는 내부 state 초기화
// 추가로 DOM의 value도 초기화할 수 있음
if (inputRef.current) {
inputRef.current.value = '';
}
}
};
return (
<div style={{ position: 'relative' }}>
<input
ref={inputRef}
value={currentValue}
onChange={handleChange}
{...props}
/>
{currentValue && (
<span
onClick={handleClearClick}
style={{
position: 'absolute',
right: 0,
top: 0,
}}
>
X
</span>
)}
</div>
);
}
MyTextInput.displayName = 'MyTextInput';
export default MyTextInput;
onChange
와 X 아이콘 클릭, DOM 값 초기화 등이 나뉘어 있어 가독성이 떨어진다.개발자가 스스로 X 아이콘과 이벤트를 모두 관리해야 하므로 “버그가 생길 여지”가 많고 “코드가 장황해지는” 경향이 있다.
type="search"
버전: 브라우저에서 자동 처리위와 같은 고민을 한 번에 해결해 주는 방법이 바로 type="search"
다.
HTML5 표준에 따르면, input[type="search"]
는 검색어 입력 필드로 인식되어, 입력값이 있을 때 자동으로 X 아이콘을 띄우고, 클릭하면 인풋을 빈 문자열로 초기화한다.
이때도 onChange
이벤트가 트리거되므로, React 제어/비제어 인풋 모두 자연스럽게 동기화된다.
import React, {forwardRef, Ref } from 'react';
interface TextInputProps extends React.InputHTMLAttributes<HTMLInputElement> {}
const MyTextInput = forwardRef<HTMLInputElement, TextInputProps>(({
...props
}, ref: Ref<HTMLInputElement>) => {
return (
<input
type='search'
ref={ref}
{...props}
/>
);
});
MyTextInput.displayName = 'MyTextInput';
export default MyTextInput;
위처럼 작성하면, 입력값이 있을 때 브라우저가 알아서 X 아이콘을 띄우고, 클릭 시 value=""
로 지워준다.
별도의 JS 이벤트나 DOM 조작 코드를 짤 필요가 없다.
검색어 입력과 삭제가 어떻게 이뤄지는지는 위 이미지로 확인할 수 있다.
type="search"
는 내부적으로 어떻게 동작할까?HTML5 스펙에 따르면, input[type="search"]
는 "검색 필드"라는 의미를 갖는다. 이를 인지한 브라우저들은 사용자 경험을 돕기 위해 다음과 같은 요소를 기본 제공한다.
value
를 비운 뒤, onChange
이벤트를 발생시킴브라우저가 자체적으로 이 아이콘들을 렌더링하며, ::-webkit-search-cancel-button
등 CSS pseudo-element(의사 요소) 를 통해 어느 정도 스타일링할 수 있다.
┌─────────────────────────────────┐
│ [검색어 입력] ( X ) │
└─────────────────────────────────┘
위 그림은 대략적인 구조를 나타낸 것으로, 오른쪽에 X 아이콘이 표시되어 입력값이 있을 때 클릭하면 내용을 지운다.
사용자가 X 아이콘을 클릭하면, 브라우저가 내부적으로 해당 input의 value
를 빈 문자열(""
)로 바꾼다.
이때 onChange
이벤트도 자연스럽게 트리거되어, 제어(Controlled) 상태라면 setState
를 통해 빈 문자열이 반영되고, 비제어(Uncontrolled) 상태라면 DOM 자체의 value
가 비워진다.
value
를 ""
로 변경 →입력 필드의 값이 변경되어, input
이벤트가 트리거됨 → React의 onChange
이벤트 감지 → 상태값이 ""
로 업데이트 → 다시 컴포넌트에 value
가 주입되어 리렌더링.value
를 ""
로 변경 → DOM이 바로 반영 → 내부 참조(ref
등)를 통해도 항상 최신 값을 가져올 수 있음.즉, 브라우저가 값을 지우고 → React가 이벤트로 값 변경 사실을 인식하는 흐름이므로, 충돌 없이 제어/비제어 인풋에서 모두 자연스럽게 동작한다.
X 아이콘이 표시되는 타이밍(입력값이 존재할 때)과 아이콘 클릭 시 동작(값 비우기)이 모두 HTML 표준과 브라우저의 기본 로직으로 처리된다.
이것이 바로 별도의 자바스크립트 로직 없이도, 제어/비제어 인풋 어디서든 무리 없이 구동되는 핵심 이유다.
type="search"
는 사실 브라우저별로 구현이 조금씩 다르다.
예를 들어 WebKit 기반(Chrome, Safari 등)에서는 ::-webkit-search-cancel-button
이라는 CSS 의사 요소가 등장한다.
Firefox의 경우는 ::-moz-search-cancel-button
과 같은 방식이 있을 수도 있고, 어떤 브라우저는 검색 아이콘만 지원하거나 X 아이콘을 표시하지 않을 수도 있다.
다만 대다수 최신 브라우저에서 type="search"
시 X 아이콘을 표시하고, 클릭 시 value
를 비워주는 경험은 거의 비슷하게 작동한다.
브라우저마다 기본 X 아이콘 모양이 달라 통일된 디자인을 원할 때는, 아래처럼 CSS를 활용할 수 있다.
WebKit 기반(Chrome, Safari 등)에서는 ::-webkit-search-cancel-button
으로 X 아이콘을 직접 정의할 수 있다.
input[type="search"] {
appearance: none;
-webkit-appearance: none;
-moz-appearance: none;
padding-right: 28px;
}
input[type="search"]::-webkit-search-cancel-button {
-webkit-appearance: none;
/* 여기서 기본 아이콘 제거 *
background: url('/path/to/close-icon.svg') no-repeat center center;
background-size: 16px 16px;
cursor: pointer;
}
자바스크립트로 직접 아이콘을 표시하고, 클릭 이벤트를 달아 값을 비우는 방법도 나쁘진 않다.
하지만 여러 가지 예외 상황(제어 vs. 비제어 등)까지 고려하다 보면 코드가 금방 복잡해진다.
반면, type="search"
는 HTML 표준에 의해 브라우저가 알아서 제공해주는 기능이므로, 추가 로직 없이도 안정적으로 동작한다.
특히 “X 클릭 시 인풋 초기화”라는 UX가 필요하고, 복잡한 디자인 요구사항이 없는 경우라면 type="search"
가 가장 깔끔한 해법이 될 것이다.