드롭다운
components/common/Header.tsx
로그인/회원가입 버튼을 Dropdown 컴포넌트의 children으로 넣어준다. 사용자 모양 아이콘이 눌리면 드롭다운 컴포넌트가 렌더링 되고, 다시 누르면 사라진다.
...
function Header() {
...
return (
<HeaderStyle>
...
<nav className="auth">
<Dropdown toggleButton={<FaUserCircle />}>
{
isLoggedIn ?
<ul>
<li><Link to='/cart'>장바구니</Link></li>
<li><Link to='/orderlist'>주문 내역</Link></li>
<li><button onClick={storeLogout}>로그아웃</button></li>
</ul>
:
<ul>
<li>
<Link to="/login">
<FaSignInAlt />로그인
</Link>
</li>
<li>
<Link to="/signup">
<FaRegUser />회원가입
</Link>
</li>
</ul>
}
<ThemeSwitcher />
</Dropdown>
</nav >
</HeaderStyle >
);
}
...
components/common/Dropdown.tsx
드롭다운이 열려있을 때, 드롭다운 외의 영역을 클릭하면 드롭다운이 닫혀야 한다.
- ref를 Dropdown의 최상위 태그에 설정한다.
- useEffect에서 드롭다운 외의 영역 클릭 시 실행할 핸들러 함수를 정의한다.
- mousedown 이벤트 리스너를 추가한다.
unmount 할 때 이벤트 리스너를 제거하여 메모리를 절약한다.
import React, { useEffect, useRef, useState } from "react";
import styled from "styled-components";
interface Props {
children: React.ReactNode;
toggleButton: React.ReactNode;
isOpen?: boolean;
}
function Dropdown({ children, toggleButton, isOpen = false }: Props) {
const [open, setOpen] = useState(isOpen);
const dropdownRef = useRef<HTMLDivElement>(null);
useEffect(() => {
function handleOutsideClick(event: MouseEvent) {
if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) {
setOpen(false);
}
}
document.addEventListener('mousedown', handleOutsideClick);
return () => {
document.removeEventListener('mousedown', handleOutsideClick);
}
}, [dropdownRef])
return (
<DropdownStyle $open={open} ref={dropdownRef}>
<button className="toggle" onClick={() => setOpen(!open)}>
{toggleButton}
</button>
{open && <div className="panel">{children}</div>}
</DropdownStyle>
);
}
interface DropdownStyleProps {
$open: boolean;
}
const DropdownStyle = styled.div<DropdownStyleProps>`
...
`;
export default Dropdown;
탭
pages/BookDetail.tsx
content 부분을 모두 Tabs 안으로 넣고, 상세 설명, 목자, 리뷰는 각각 Tab 안으로 넣는다.
function BookDetail() {
const { bookId } = useParams();
const { book, likeToggle, reviews, addReview } = useBook(bookId);
const [isImgOpen, setIsImgOpen] = useState(false);
if (!book) return null;
return (
<BookDetailStyle>
<header className="header">
...
</header>
<div className="content">
<Tabs>
<Tab title='상세 설명'>
<Title size='medium'>상세 설명</Title>
<EllipsisBox linelimit={4}>
{book.detail}
</EllipsisBox>
</Tab>
<Tab title='목차'>
<Title size='medium'>목차</Title>
<p className="index">{book.contents}</p>
</Tab>
<Tab title='리뷰'>
<Title size="medium">리뷰</Title>
<BookReview reviews={reviews} onAdd={addReview} />
</Tab>
</Tabs>
</div>
</BookDetailStyle>
);
}
components/common/Tabs.tsx
React.Children.toArray 함수를 사용하면 children으로 받은 각 Tab들을 배열로 만들 수 있다. 각 Tab을 순회하며 화면에 표시하고, activeIndex를 이용하여 현재 활성화되어 있는 탭일 경우 색을 다르게 표시한다.
...
interface TabProps {
title: string;
children: React.ReactNode;
}
function Tab({ children }: TabProps) {
return (
<>{children}</>
);
}
interface TabsProps {
children: React.ReactNode;
}
function Tabs({ children }: TabsProps) {
const [activeIndex, setActiveIndex] = useState(0);
const tabs = React.Children.toArray(children) as React.ReactElement<TabProps>[];
return (
<TabsStyle>
<div className="tab-header">
{
tabs.map((tab, index) => (
<button
onClick={() => setActiveIndex(index)}
className={activeIndex === index ? 'active' : ''}
>
{tab.props.title}
</button>
))
}
</div>
<div className="tab-content">
{tabs[activeIndex]}
</div>
</TabsStyle>
);
}
...
토스트
store/toastStore.ts
토스트 알림은 전역에서 관리되어야 하기 때문에 store을 생성한다.
import create from "zustand"
export type ToastType = 'info' | 'error';
export interface ToastItem {
id: number;
message: string;
type: ToastType;
}
interface ToastStoreState {
toasts: ToastItem[];
addToast: (message: string, type?: ToastType) => void;
removeToast: (id: number) => void;
}
const useToastStore = create<ToastStoreState>((set) => ({
toasts: [],
addToast: (message, type = 'info') => {
set((state) => ({
toasts: [...state.toasts, { message, type, id: Date.now() }]
}));
},
removeToast: (id) => {
set((state) => ({
toasts: state.toasts.filter((toast) => toast.id !== id)
}))
}
}))
export default useToastStore;
hooks/useToast.ts
토스트를 추가하는 훅 함수를 정의한다.
export const useToast = () => {
const showToast = useToastStore((state) => state.addToast);
return { showToast };
}
hooks/useTimeout.ts
일정 시간이 흐른 후에 토스트를 삭제하기 위한 timeout 훅을 작성한다.
useEffect의 return에서 타이머를 clear 해줘야 메모리를 낭비하지 않는다.
import { useEffect } from "react"
export const useTimeout = (callback: () => void, delay: number) => {
useEffect(() => {
const timer = setTimeout(callback, delay);
return () => clearTimeout(timer);
}, [callback, delay]);
}
export default useTimeout;
hooks/useBook.ts
좋아요 버튼을 누르면 토스트 알림이 나타나도록 설정한다.
export const useBook = (bookId: string | undefined) => {
...
const likeToggle = () => {
...
const { showToast } = useToast();
if (book.liked) {
// unlike 실행
...
showToast('좋아요가 취소되었습니다.');
} else {
// like 실행
...
showToast('좋아요가 성공했습니다.');
}
};
...
}
App.tsx
토스트 컨테이너를 추가한다.
...
function App() {
return (
<QueryClientProvider client={queryClient}>
<BookStoreThemeProvider>
<RouterProvider router={router} />
<ToastConttainer />
</BookStoreThemeProvider>
</QueryClientProvider>
)
}
...
components/common/toast/ToastContainer.tsx
store에 저장되어 있는 토스트들을 Toast 컴포넌트를 사용하여 모두 렌더링 한다.
...
function ToastConttainer() {
const toasts = useToastStore((state) => state.toasts);
return (
<ToastConttainerStyle>
{
toasts.map((toast) => (
<Toast
key={toast.id}
id={toast.id}
message={toast.message}
type={toast.type}
/>
))
}
</ToastConttainerStyle>
);
}
...
components/common/toast/Toast.tsx
fade-out 애니메이션 적용을 위해 isFadingOut 상태를 정의한다.
X 버튼을 클릭하거나 지정된 시간이 지나면 isFadingOut을 참으로 설정하고,
fade-out 애니메이션이 끝나면 store에 저장되어 있던 해당 토스트를 삭제한다.
export const TOAST_REMOVE_DELAY = 3000;
function Toast({ id, message, type }: ToastItem) {
const removeToast = useToastStore((state) => state.removeToast);
const [isFadingOut, setIsFadingOut] = useState(false);
const handleRemoveToast = () => {
setIsFadingOut(true);
}
useTimeout(() => {
setIsFadingOut(true);
}, TOAST_REMOVE_DELAY);
const handleAnimationEnd = () => {
if (isFadingOut) {
removeToast(id);
}
};
return (
<ToastStyle
className={isFadingOut ? 'fade-out' : 'fade-in'}
onAnimationEnd={handleAnimationEnd}
>
<p>
{type === 'info' && <FaInfoCircle />}
{type === 'error' && <FaBan />}
{message}
</p>
<button onClick={handleRemoveToast}>
<FaPlus />
</button>
</ToastStyle>
);
}
모달
components/common/Modal.tsx
fade-out 적용을 위해 isFadingOut 상태를 정의하고, 모달 창 외부를 클릭하면 모달 창이 닫히도록 ref를 사용한다.
esc 키가 눌려도 모달 창이 닫히도록 useEffect를 사용하여 이벤트 리스너를 등록한다.
모달 창은 포탈을 사용하여 body의 바로 아래에 위치하도록 한다. body의 바로 아래에 있지 않으면 다른 기능을 추가했을 때 문제가 발생할 수도 있기 때문이다.
interface Props {
children: React.ReactNode;
isOpen: boolean;
onClose: () => void;
}
function Modal({ children, isOpen, onClose }: Props) {
const [isFadingOut, setIsFadingOut] = useState(false);
const modalRef = useRef<HTMLDivElement | null>(null);
const handleClose = () => {
setIsFadingOut(true);
}
const handleOverlayClick = (e: React.MouseEvent) => {
if (modalRef.current && !modalRef.current.contains(e.target as Node)) {
onClose();
}
}
const handleKeydown = (e: KeyboardEvent) => {
if (e.key === 'Escape') {
handleClose();
}
}
useEffect(() => {
if (isOpen) {
window.addEventListener('keydown', handleKeydown);
} else {
window.removeEventListener('keydown', handleKeydown);
}
return () => {
window.removeEventListener('keydown', handleKeydown);
}
}, [isOpen])
const handleAnimationEnd = () => {
if (isFadingOut) {
onClose();
}
}
if (!isOpen) return null;
return createPortal(
<ModalStyle
className={isFadingOut ? 'fade-out' : 'fade-in'}
onClick={handleOverlayClick}
onAnimationEnd={handleAnimationEnd}
>
<div className="modal-body" ref={modalRef}>
<div className="modal-content">{children}</div>
<button className="modal-close" onClick={handleClose}>
<FaPlus />
</button>
</div>
</ModalStyle>
, document.body
);
}
무한 스크롤
hooks/useBooksInfinite.ts
react-query 라이브러리에 무한 스크롤을 위한 useInfiniteQuery가 있다. 첫 번째 인자는 쿼리의 키, 두 번째 인자는 실행할 함수, 세 번째 인자는 옵션이다. url의 쿼리가 변경될 때마다 getBooks로 책 목록을 가져오며, 현재 페이지가 마지막 페이지인지 확인하여 getNextPageParam에 null 또는 다음 페이지 번호를 저장한다.
...
export const useBooksInfinite = () => {
const location = useLocation();
const params = new URLSearchParams(location.search);
const getBooks = ({ pageParam }: { pageParam: number }) => {
const params = new URLSearchParams(location.search);
const category_id = params.get(QUERYSTRING.CATEGORY_ID) ?
Number(params.get(QUERYSTRING.CATEGORY_ID)) : undefined;
const news = params.get(QUERYSTRING.NEWS) ? true : undefined;
const limit = LIMIT;
const current_page = pageParam;
return fetchBooks({
category_id,
news,
limit,
current_page
})
}
const { data, fetchNextPage, hasNextPage, isFetching } = useInfiniteQuery(
['books', location.search],
({ pageParam = 1 }) => getBooks({ pageParam }),
{
getNextPageParam: (lastPage) => {
const isLastPage =
Math.ceil(lastPage.pagination.totalCount / LIMIT) === lastPage.pagination.currentPage;
return isLastPage ? null : lastPage.pagination.currentPage + 1;
}
}
)
const books = data ? data.pages.flatMap((page) => page.books) : [];
const pagination = data ? data.pages[data.pages.length - 1].pagination : {};
const isEmpty = books.length === 0;
return {
books: books,
pagination: pagination,
isEmpty: isEmpty,
isBooksLoading: isFetching,
fetchNextPage,
hasNextPage
}
}
hooks/useIntersectionObserver.ts
import { useEffect, useRef } from "react";
type Callback = (entries: IntersectionObserverEntry[]) => void;
interface ObserverOptions {
root?: Element | null;
rootMargin?: string;
threshold: number | number[];
}
export const useIntersectionObserver = (callback: Callback, options?: ObserverOptions) => {
const targetRef = useRef(null);
useEffect(() => {
const observer = new IntersectionObserver(callback, options);
if (targetRef.current) {
observer.observe(targetRef.current);
}
return () => {
if (targetRef.current) {
observer.unobserve(targetRef.current);
}
}
});
return targetRef;
}
pages/Books.tsx
교차 대상인 더 보기 버튼을 감싸고 있는 div에 다다르면 fetchNextPage로 다음 페이지를 자동으로 로드한다.
function Books() {
const {
books,
pagination,
isEmpty,
isBooksLoading,
fetchNextPage,
hasNextPage
} = useBooksInfinite();
const moreRef = useIntersectionObserver(([entry]) => {
if (entry.isIntersecting) {
loadMore();
}
})
const loadMore = () => {
if (!hasNextPage) return;
fetchNextPage();
}
if (isBooksLoading) {
return <Loading />;
}
return (
<>
<Title size='large'>도서 검색 결과</Title>
<BooksStyle>
<div className='filter'>
<BooksFilter />
<BooksViewSwitcher />
</div>
{
books && pagination &&
<>
<BooksList books={books} />
{/* <Pagination pagination={pagination} /> */}
</>
}
<div className="more" ref={moreRef}>
<Button
size='medium'
scheme='normal'
onClick={() => fetchNextPage()}
disabled={!hasNextPage}
>
{hasNextPage ? '더보기' : '마지막 페이지'}
</Button>
</div>
{isEmpty && <BooksEmpty />}
</BooksStyle>
</>
)
}
배운 점
- useRef를 사용하여 ref의 영역 외의 공간이 클릭되면 state를 false로 설정하는 방법을 배웠다.
- onAnimationEnd 이벤트로 fade-out 애니메이션을 적용할 수 있다.
- portal로 컴포넌트를 document의 원하는 위치로 이동시킬 수 있다.
- react-query의 useInfiniteQuery와 IntersectionObserver을 사용하면 무한 스크롤을 구현할 수 있다.
'데브코스' 카테고리의 다른 글
[16주차 - DAY4] 도서 정보 사이트 - 모바일 대응, 도커 (0) | 2024.06.12 |
---|---|
[16주차 - DAY3] 도서 정보 사이트 - 메인 화면 (1) | 2024.06.12 |
[16주차 - DAY1] 도서 정보 사이트 - 리뷰 (0) | 2024.06.10 |
[15주차 - DAY5] 스니펫 (0) | 2024.06.07 |
[15주차 - DAY5] 도서 정보 사이트 - 리팩토링 (0) | 2024.06.07 |