전체코드 (관련 코드는 SearchPage와 Search 폴더에 있음)
키보드로 검색어 선택 기능은 2편에서
검색어 자동완성 기능은 의외로 자주 사용되는 UI이기 때문에 디바운스 연습할 겸
미국의 도시명을 검색할 수 있는 검색창 및 자동완성 기능을 만들어보기로 했다.
+참고) debouce와 throttle의 차이
- 디바운스: 어떤 이벤트가 연속으로 들어오면 무시하고 마지막 이벤트만 인식하는 것
- 쓰로틀링: 어떤 이벤트가 연속으로 들어오면 일정한 간격 마다 인식하는 것
검색어 자동 완성은 debounce를 적용하기에 적절한 케이스이다. 만약 '아버지가방에들어가신다'라고 입력한다면,
'ㅇ', 'ㅏ', 'ㅂ', 'ㅓ' 처럼 '아버' 를 치는데만도 벌써 API 호출이 무수히 일어나기 때문이다. 보통 유저가 검색어 입력을 잠깐 머뭇거리는 1초의 틈에 그동안의 단어를 모았다가 한꺼번에 API 호출을 하도록 debounce를 적용한다.
계획
보통은 매 입력마다 서버에 쿼리를 날려 그와 일치하는 검색어 목록을 받아오지만, 그런 서버가 없으므로 처음 페이지에 접속할 때 도시의 목록을 불러오고 그것을 필터링해서 검색을 흉내낼 것이다.
- 도시 목록 json 데이터를 axios로 불러오는 hook 작성
- useDebounce hook 작성
- 마크업
- 검색어 필터링 함수 구현
- 검색어 자동완성 창에서 키보드로 선택하는 기능 구현
- 선택하면 구글 맵 검색 페이지를 띄워주도록 기타 이벤트 등을 추가
와 같은 흐름으로 구현할 것이다.
구현하기
1. json 데이터 불러오기
미국의 주와 도시 목록 데이터를 불러온다. json 파일 출처는 여기
https://github.com/mattearly/USA_States_and_Cities_json
프로젝트 내에서 절대 경로로 데이터를 fetch하고 싶으면 public 폴더 아래에 파일을 위치시키면 된다.
도시 정보를 가져오는 hook을 만들어줬다. (데이터를 가져올 때 예외 처리 하는 것 잊지말자!)
// src/_lib/useFetch
import { useState, useEffect } from 'react';
import axios from 'axios';
function useFetchCities() {
const [cityList, setCityList] = useState({});
useEffect(() => {
const fetchCityList = async () => {
try {
const result = await axios.get('/usa_states_and_cities.json');
setCityList(result.data.countries.USA);
} catch (e) {
window.alert('에러' + e);
}
};
fetchCityList();
}, []);
return cityList;
}
export default useFetchCities;
2. useDebounce hook 만들기
작성한 useDebounce hook은 SearchPage에서 다음과 같이 사용할 예정이다.
// src/pages/SearchPage
const [tmpQuery, setTmpQuery] = useState('');
const query = useDebounce(tmpQuery, 500);
useEffect(() => {
setPreviewList(filterCity(cityList, query));
}, [query, cityList]);
input값이 onChange로 tmpQuery에 설정되고, 디바운스로 delay된 값이 최종적으로 query에 들어간다.
그러면 query값이 바뀐 것을 useEffect가 감지하고 해당하는 도시를 필터링해 검색어 자동완성 목록을 보여주게 된다.
(만약 필터링 방식으로 구현하지 않고 서버가 목록을 주는 형태였다면, useEffect에서 대신 API 호출을 하면 된다.)
useDebounce.ts
// src/hooks/useDebounce.ts
import { useState, useEffect } from 'react';
function useDebounce<T>(value: T, delay: number) {
const [debouncedValue, setDebouncedValue] = useState(value);
useEffect(() => {
const handler = setTimeout(() => {
setDebouncedValue(value);
}, delay);
return () => {
clearTimeout(handler);
};
}, [value, delay]);
return debouncedValue;
}
export default useDebounce;
value는 tmpQuery처럼 계속 바뀌는 입력값이다.
이 hook을 여러 곳에서 재사용하다보면 입력값의 타입이 다를 수 있기 때문에 제네릭을 이용해 입력값의 타입을 유동적으로 받을 수 있게 했다.
debounce 구현 원리
- useEffect는 의존배열(dependencies)가 바뀔 때마다 이전 effect를 정리한다.
- timer를 설정하고,
-> 만약 타이머가 다 끝나기 전 새로운 입력이 발생했다면, return () => {} 부분을 실행해 타이머를 해제하고 새로운 타이머를 설정한다.
-> 만약 타이머가 끝날 동안 입력이 없어 의존배열이 바뀌지 않는다면, delay초가 지난 다음 debounce된 값을 반환하게 된다. - 검색어 입력을 연속적으로 하다가 잠깐 머뭇거리거나 멈추는 틈에 타이머가 정리되지 않고 온전히 끝나게 되면, 성공적으로 마지막 값이 반환되는게 debounce의 원리이다.
위에 나와있는 사용 예시는 <string>이 생략된 형태이다.
const query = useDebounce<string>(tmpQuery, 500);
const query = useDebounce(tmpQuery, 500);
왜냐하면 tmpQuery는 string 타입이라는 것을 TS가 알아서 추측할 수 있기 때문에 생략해도 된다.
3. 마크업
(전체 완성 결과에서 css만 참고하라고 해당 부분만 발라놓은 것이기 때문에 빠진 속성이나 뜬금없이 튀어나온 속성이 있을 수 있다.)
SearchPage.tsx
import styled from 'styled-components';
import SearchForm from '../Search/SearchForm';
function SearchPage() {
return (
<Container>
<Title>Search for Cities</Title>
<SearchForm />
{query && (<SearchPreview previewList={previewList} />)}
// 검색창에 입력값이 존재할 때만 자동완성을 보여줌.
// 필터링한 결과값을 previewList 배열에 담아 자동완성에 내려준다.
)}
</Container>
);
}
const Title = styled.h1`
margin: 200px 0 30px 0;
`;
const Container = styled.div`
height: 100%;
display: flex;
flex-direction: column;
align-items: center;
`;
export default SearchPage;
SearchForm.tsx
import SearchInput from '../Search/SearchInput';
import SearchButton from '../Search/SearchButton';
function SearchForm() {
const onSubmitHandler = (e: React.FormEvent) => {
e.preventDefault();
};
return (
<SearchFormBlock onSubmit={onSubmitHandler}>
<SearchInput />
<SearchButton />
</SearchFormBlock>
);
}
const SearchFormBlock = styled.form<SerachFormBlock>`
background-color: #4654e1;
width: 340px;
height: 44px;
border-radius: 5px 5px 0 0;
display: flex;
flex-direction: row;
align-items: center;
`;
export default SearchForm;
SearchInput.tsx, SearchButton.tsx
function SearchInput() {
return <SearchInputBlock />;
}
const SearchInputBlock = styled.input`
all: unset;
font: 16px system-ui;
color: #fff;
height: 100%;
width: 100%;
padding: 6px 10px;
`;
export default SearchInput;
(검색창에 입력값이 있을 때 border 밑부분을 뾰족하게 하는 Input 디테일은 이 글에서는 생략했다. 다음 글이나 깃허브의 전체 코드 부분에서 확인할 수 있다)
function SearchButton() {
return (
<SearchButtonBlock>
<SVG viewBox="0 0 1024 1024">
<path d="M848.471 928l-263.059-263.059c-48.941 36.706-110.118 55.059-177.412 55.059-171.294 0-312-140.706-312-312s140.706-312 312-312c171.294 0 312 140.706 312 312 0 67.294-24.471 128.471-55.059 177.412l263.059 263.059-79.529 79.529zM189.623 408.078c0 121.364 97.091 218.455 218.455 218.455s218.455-97.091 218.455-218.455c0-121.364-103.159-218.455-218.455-218.455-121.364 0-218.455 97.091-218.455 218.455z" />
</SVG>
</SearchButtonBlock>
);
}
const SearchButtonBlock = styled.button`
all: unset;
cursor: pointer;
width: 44px;
height: 44px;
`;
const SVG = styled.svg`
color: #fff;
fill: currentColor;
width: 100%;
height: 100%;
padding: 5px;
`;
export default SearchButton;
SearchPreview.tsx
SearchPage로부터 previewList(검색어 자동완성) 리스트를 받아 보여준다.
검색결과가 없으면 No Matching Results를, 있으면 리스트 Item을 보여준다.
검색 결과를 클릭하면 구글맵으로 이동하게 해봤다.
interface SearchPreviewProps {
previewList: string[];
}
function SearchPreview({ previewList }: SearchPreviewProps) {
return (
<SearchPreviewList>
{previewList.length === 0 ? (
<SearchPreviewItem>
<p>No Matching Results</p>
</SearchPreviewItem>
) : (
previewList.map((item, index) => {
const location = item.split('>');
return (
<SearchPreviewItem key={index}>
<Link
to={`https://www.google.com/maps/place/${location[0]}+${location[1]}`}
// location[0]은 주, location[1]은 도시
target="_blank"
>
<p>{item}</p>
</Link>
</SearchPreviewItem>
);
})
)}
</SearchPreviewList>
);
}
const SearchPreviewList = styled.ul`
width: 340px;
max-height: 300px;
background-color: #fcfcfc;
border-radius: 0 0 5px 5px;
padding: 5px 0;
overflow-y: scroll;
&::-webkit-scrollbar {
display: none;
}
a {
text-decoration-line: none;
color: black;
}
`;
const SearchPreviewItem = styled.li<SearchPreviewItemProps>`
height: 4.4rem;
border-bottom: 1px solid #dedede;
vertical-align: center;
padding: 0 10px;
background-color: ${(props) => props.isFocused && '#efefef'};
&:hover {
background-color: #efefef;
}
&:last-child {
border: none;
}
p {
font-size: 1.8rem;
line-height: 4.4rem;
}
`;
4. 도시 검색 (필터링) 함수 작성
filterCity()
첫번째 인자로는 json 형태의 도시 데이터를, 두번째 인자로는 검색어를 받는다.
// src/_lib/utils
export const filterCity = (
cityList: { [key: string]: string[] },
query: string
) => {
// state를 돌며 하위의 도시 중 일치하는 것들을 배열에 담아 반환한다. => 이중 순회문 사용
const states = Object.keys(cityList);
const matchedResults: string[] = [];
states.forEach((state) => {
cityList[state].forEach((city) => {
if (query === '') {
return;
} else if (city.toLowerCase().startsWith(query.toLowerCase())) {
matchedResults.push(`${state} > ${city}`);
}
});
});
return matchedResults;
};
참고로 도시 데이터가 {} (Object) 형태에 담겨 있으므로 다음과 같이 타입을 설정해줬다.
cityList: { [key: string]: string[] }
그리고 Object를 그냥 forEach로 순회할 수는 없기 때문에, Object.keys로 50개 주(state)를 배열로 뽑아낸 다음,
states를 순회하며 cityList[state]로 그 안의 배열에 접근했다.
내가 지금 도시 이름을 찾는 것 같은 상황에서는 indexOf()보다 startsWith()로 찾는 것이 더 적합하다.
찾는 대상이 문장이 아닌 한 두개의 단어로 이루어져있고, 쌩뚱맞게 중간 글자만 알고 있는게 아니라 앞글자부터 입력하고 있다는 전제하에 찾는 것이기 때문이다.
중간 글자가 일치하는 것을 찾는 경우가 배제되었기 때문에 startsWith()가 더 성능이 좋다.
키보드로 검색어 선택은
'UI 구현 연구일지' 카테고리의 다른 글
[React] useInfiniteQuery와 Intersection Observer 이용한 무한스크롤 구현 👀 unsplash 이미지 무한으로 불러오기 (0) | 2023.07.26 |
---|---|
[React] Debounce(디바운스) 적용한 검색어 자동완성 구현기 (2) - 검색어 키보드로 선택하기 (0) | 2023.06.14 |
[React 컴포넌트] 댓글 기능 구현하기 (0) | 2022.07.12 |
[React 컴포넌트] Like, Dislike (좋아요, 싫어요) 기능 만드는 법 (0) | 2022.07.11 |
[React 컴포넌트] Favorite(찜, 좋아요) 버튼 만드는 법 (0) | 2022.07.11 |