2024년 11월 20일
자바스크립트 MSW 라이브러리 사용할 것임(Mocking response)
Mocking: 실제로 존재 하지 않는 것을 가상으로 만들어서 응답을 받음
마치 node 서버를 가상으로 띄워놓은 것처럼(실제로 백엔드에 존재하지 않는 node 서버를 띄운 것처럼) 실제로 api 응답을 mocking함
MSW
Mock Service Worker
존재 하지 않는 API 에 대한 응답을 모킹
service worker 에서 요청을 처리
chrome 기준 dev Tool의 Application / Service workers 의 “Bypass for network”로 일시 정지
리뷰 추가
도서 상세 페이지에 리뷰를 넣을 예정 with mock server
테스트 환경 설정
각 리뷰에 대한 모델 작성
// models / book.modelts
... 생략 ...
export interface BookReviewItem {
id: number;
userName: string;
content: string;
createdAt: string;
score: number;
}
Review api 작성
// api / review.api
import { BookReviewItem } from '@/models/book.model';
import { requestHandler } from './http';
export const fetchBookReview = async (bookId: string) => {
return await requestHandler<BookReviewItem>("get", `/reviews/${bookId}`);
}
훅에서 api 사용해서 데이터 요청 & 훅을 이용해 컴포넌트에 넣기
// hooks / useBooks.ts
... 생략 ...
export const useBook = (bookId: string | undefined) => {
const [book, setBook] = useState<BookDetail | null>(null);
const [cartAdded, setCartAdded] = useState(false);
const [reviews, setReviews] = useState<BookReviewItem[]>([]);
... 생략 ...
useEffect(()=>{
if(!bookId) return;
fetchBook(bookId).then((book) => { setBook(book) });
fetchBookReview(bookId).then((reviews)=> { setReviews(reviews) });
}, [bookId]);
return { book, likeToggle, addToCart, cartAdded, reviews };
}
// pages / BookDetail.tsx
... 생략 ...
const BookDetail = () => {
const { bookId } = useParams();
const { book, likeToggle, reviews } = useBook(bookId);
... 생략 ...
MSW 도입
index.tsx에 해당 내용 적용 → ServiceWorker 실행 (mock 서버 사용 준비)
// index.tsx
import React from 'react';
import ReactDOM from 'react-dom/client';
import App from './App';
import { state, ThemeContext } from './context/themeContext';
if(process.env.NODE_ENV === "development") {
const { worker } = require("./mock/browser");
worker.start();
}
const root = ReactDOM.createRoot(
document.getElementById('root') as HTMLElement
);
root.render(
<React.StrictMode>
<App />
</React.StrictMode>
);
// mock / browser.ts
import { setupWorker } from "msw/browser";
import { reviewsById } from './review';
const handlers = [reviewsById];
export const worker = setupWorker(...handlers);
// mock / review.ts
import { BookReviewItem } from '@/models/book.model';
import { http, HttpResponse } from "msw";
export const reviewsById = http.get("http://127.0.0.1:9999/reviews/:bookId",
() => {
const data:BookReviewItem[] = [];
return HttpResponse.json(data, {
status: 200,
});
});
// index.tsx
import React from 'react';
import ReactDOM from 'react-dom/client';
import App from './App';
async function mountApp () {
if(process.env.NODE_ENV === "development") {
const { worker } = require("./mock/browser");
await worker.start(); // msw 시작
}
const root = ReactDOM.createRoot(
document.getElementById('root') as HTMLElement
);
root.render(
<React.StrictMode>
<App />
</React.StrictMode>
);
}
mountApp();
// mock / review.ts
import { BookReviewItem } from '@/models/book.model';
import { http, HttpResponse } from "msw";
import { fakerKO as faker } from "@faker-js/faker";
const mockReviewsData: BookReviewItem[] = Array.from({length: 8}).map((_, index) =>
({
id: index,
userName: `${faker.person.lastName()}${faker.person.firstName()}`,
content: faker.lorem.paragraph(),
createdAt: faker.date.past().toISOString(),
score: (faker.helpers as any).rangeToNumber({min: 1, max: 5}),
}));
export const reviewsById = http.get("http://127.0.0.1:9999/reviews/:bookId",
() => {
return HttpResponse.json(mockReviewsData, {
status: 200,
});
});
// pages / BookDetail.tsx
... 생략 ...
const BookDetail = () => {
const { bookId } = useParams();
const { book, likeToggle, reviews } = useBook(bookId);
... 생략 ...
<Title size="medium">리뷰</Title>
<BookReview reviews={reviews}/>
... 생략 ...
// components / book / BookReview.tsx
import { BookReviewItem as IBookReviewItem} from '@/models/book.model';
import styled from 'styled-components';
import BookReviewItem from './BookReviewItem';
interface Props {
reviews: IBookReviewItem[];
}
const BookReview = ({ reviews }: Props) => {
return (
<BookReviewStyle>
{
reviews.map((review)=>(<BookReviewItem review={review }/>))
}
</BookReviewStyle>
);
}
const BookReviewStyle = styled.div`
display: flex;
flex-direction: column;
gap: 16px;
`;
export default BookReview;
// components / book / BookReviewItem.tsx
import { BookReviewItem as IBookReviewItem} from '@/models/book.model';
import { formatDate } from '@/utils/format';
import styled from 'styled-components';
interface Props {
review: IBookReviewItem;
}
const BookReviewItem = ({review} : Props) => {
return (
<BookReviewItemStyle>
<header className="header">
<div>
<span>{review.userName}</span>
<span>{review.score}</span>
</div>
<div>{formatDate(review.createdAt)}</div>
</header>
<div className="content">
<p>
{review.content}
</p>
</div>
</BookReviewItemStyle>
);
}
const BookReviewItemStyle = styled.div`
display: flex;
flex-direction: column;
gap: 12px;
box-shadow: 0 0 4px rgba(0, 0, 0, 0.2);
padding: 12px;
border-radius: ${({theme}) => theme.borderRadius.default};
.header {
display: flex;
justify-content: space-between;
align-items: center;
font-size: 0.875rem;
color: ${({theme}) => theme.colors.secondary};
padding: 0;
}
.content {
p {
font-size: 1rem;
line-height: 1.5;
margin: 0;
}
}
`;
export default BookReviewItem;
// components / book / BookReviewItem.tsx
import { BookReviewItem as IBookReviewItem} from '@/models/book.model';
import { formatDate } from '@/utils/format';
import styled from 'styled-components';
import { FaStar } from 'react-icons/fa';
interface Props {
review: IBookReviewItem;
}
const Star = (props: Pick<IBookReviewItem, "score">) => {
return(
<span className="star">
{
Array.from({ length: props.score }, (_, index) => (
<FaStar />
))
}
</span>
)
}
const BookReviewItem = ({review} : Props) => {
return (
<BookReviewItemStyle>
<header className="header">
<div>
<span>{review.userName}</span>
<Star score={review.score} />
</div>
<div>{formatDate(review.createdAt)}</div>
</header>
<div className="content">
<p>
{review.content}
</p>
</div>
</BookReviewItemStyle>
);
}
const BookReviewItemStyle = styled.div`
display: flex;
flex-direction: column;
gap: 12px;
box-shadow: 0 0 4px rgba(0, 0, 0, 0.2);
padding: 12px;
border-radius: ${({theme}) => theme.borderRadius.default};
.header {
display: flex;
justify-content: space-between;
align-items: center;
font-size: 0.875rem;
color: ${({theme}) => theme.colors.secondary};
padding: 0;
.star {
padding: 0 0 0 8px;
svg {
fill: ${({theme}) => theme.colors.primary};
padding: 0;
}
}
}
.content {
p {
font-size: 1rem;
line-height: 1.5;
margin: 0;
}
}
`;
export default BookReviewItem;
// mock / review.ts
... 생략 ...
export const addReview = http.post("http://127.0.0.1:9999/reviews/:bookId", () => {
return HttpResponse.json({
message: "리뷰가 등록되었습니다",
},
{
status: 200,
}
)
});
// mock / browser.ts
import { setupWorker } from "msw/browser";
import { addReview, reviewsById } from './review';
const handlers = [reviewsById, addReview];
export const worker = setupWorker(...handlers);
// models / book.model.ts
... 생략 ...
export interface BookReviewItem {
id: number;
userName: string;
createdAt: string;
content: string;
score: number;
}
export type BookReviewItemWrite = Pick<BookReviewItem, "content" | "score">;
// api / review.api.ts
... 생략 ...
interface AddBookReviewResponse {
message: string;
}
export const addBookReview = async (bookId: string, data: BookReviewItemWrite) => {
return await requestHandler<AddBookReviewResponse>("post", `/reviews/${bookId}`);
}
// hooks / useBook.ts
... 생략 ...
const addReview = (data:BookReviewItemWrite) => {
if(!book) return;
addBookReview(book.id.toString(), data).then((res) => {
// fetchBookReview(book.id.toString()).then((reviews)=>
// { setReviews(reviews) });
showAlert(res?.message);
})
}
return { book, likeToggle, addToCart, cartAdded, reviews, addReview };
}
// pages / BookDetail.tsx
... 생략 ...
<div className="content">
<Title size="medium">상세 설명</Title>
<EllipsisBox linelimit={2}>{book.detail}</EllipsisBox>
<Title size="medium">목차</Title>
<p className="index">{book.contents}</p>
<Title size="medium">리뷰</Title>
<BookReview reviews={reviews} onAdd={addReview}/>
</div>
</BookDetailStyle>
)
}
... 생략 ...
// components / book / BookReview.tsx
... 생략 ...
interface Props {
reviews: IBookReviewItem[];
onAdd: (data: BookReviewItemWrite) => void;
}
const BookReview = ({ reviews, onAdd }: Props) => {
return (
<BookReviewStyle>
<BookReviewAdd onAdd={onAdd} />
{
reviews.map((review)=>(<BookReviewItem key={review.id} review={review }/>))
}
</BookReviewStyle>
);
}
... 생략 ...
// components / book / BookReviewAdd.tsx
import { BookReviewItemWrite } from '@/models/book.model';
import { useForm } from 'react-hook-form';
import styled from 'styled-components';
import Button from '../common/Button';
interface Props {
onAdd: (data: BookReviewItemWrite) => void;
}
const BookReviewAdd = ({ onAdd } : Props) => {
const { register, handleSubmit, formState: {errors} } = useForm<BookReviewItemWrite>();
return (
<BookReviewAddStyle>
<form onSubmit={handleSubmit(onAdd)}>
<fieldset>
<textarea {...register("content", { required: true })}></textarea>
{errors.content && <p className='error-text'>리뷰 내용을 입력해 주세요.</p>}
</fieldset>
<div className="submit">
<fieldset>
<select {...register("score", { required: true, valueAsNumber: true })}>
<option value="1">1점</option>
<option value="2">2점</option>
<option value="3">3점</option>
<option value="4">4점</option>
<option value="5">5점</option>
</select>
</fieldset>
<Button size="medium" scheme="primary">작성하기</Button>
</div>
</form>
</BookReviewAddStyle>
);
}
const BookReviewAddStyle = styled.div`
form {
display: flex;
flex-direction: column;
gap: 6px;
fieldset {
border: 0;
padding: 0;
margin: 0;
display: flex;
flex-direction: column;
gap: 12px;
justify-content: end;
.error-text {
color: red;
padding: 0;
margin: 0;
}
}
textarea {
width: 100%;
height: 100px;
border-radius: ${({theme}) => theme.borderRadius.default};
border: 1px solid ${({theme}) => theme.colors.border};
padding: 12px;
}
}
.submit {
display: flex;
justify-content: end;
}
`;
export default BookReviewAdd;
// components / common / Dropdown.tsx
import { useEffect, useRef, useState } from 'react';
import styled from 'styled-components';
interface Props {
children: React.ReactNode;
toggleButton: React.ReactNode;
isOpen?: boolean;
}
const 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)) {
// ref가 존재하고, ref 안에 클릭한 부분이 없을 때만 동작한다. -> 외부 클릭
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>`
position: relative;
button {
background: none;
border: none;
cursor: pointer;
outline: none;
svg {
width: 30px;
height: 30px;
fill: ${({theme, $open}) => $open ? theme.colors.primary : theme.colors.text};
}
}
.panel {
position: absolute;
top: 40px;
right: 0;
padding: 16px;
background: #fff;
box-shadow: 0 0 10px rgba(0, 0, 0, 0.1);
border-radius: ${({theme}) => theme.borderRadius.default};
z-index: 10;
}
`;
export default Dropdown;
// components / common / Header.tsx
... 생략 ...
<nav className="auth">
<Dropdown toggleButton={<FaUserCircle />}>
<>
{
isloggedIn && (
<ul>
<li><Link to="/carts">장바구니</Link></li>
<li><Link to="/orderlist">주문 내역</Link></li>
<li><button onClick={storeLogout}>로그아웃</button></li>
</ul>
)
}
{
!isloggedIn && (
<ul>
<li>
<a href="/login"><FaSignInAlt />로그인</a>
</li>
<li>
<a href="/signup"><FaRegUser />회원가입</a>
</li>
</ul>
)
}
<ThemeSwitcher />
</>
</Dropdown>
</nav>
... 생략 ...
// pages / BookDetail.tsx
... 생략 ...
<div className="content">
<Tabs>
<Tab title="상세설명">
<Title size="medium">상세 설명</Title>
<EllipsisBox linelimit={2}>{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>
... 생략 ...
// components / common / Tabs.tsx
import React, { useState } from 'react';
import styled from 'styled-components';
interface TabProps {
title: string;
children: React.ReactNode;
}
const Tab = ({ children }: TabProps) => {
return(
<>{children}</>
)
}
interface TabsProps {
children: React.ReactNode;
}
const Tabs = ({ children }: TabsProps) => {
const [activeIndex, setActiveIndex] = useState(0);
const tabs = React.Children.toArray(children) as React.ReactElement<TabProps>[];
// 직접 access 할 수 있는 배열로 바꿈
return (
<TabsStyle>
<div className="tab-header">
{
tabs.map((tab, index)=>(
<button key={tab.props.title} className={activeIndex === index ?
"active" : ""} onClick={()=>{setActiveIndex(index)}}>
{tab.props.title}</button>
))
}
</div>
<div className="tab-content">
{tabs[activeIndex]}
</div>
</TabsStyle>
);
}
const TabsStyle = styled.div`
.tab-header {
display: flex;
gap: 2px;
border-bottom: 1px solid #ddd;
button {
border: none;
background: #ddd;
cursor: pointer;
font-size: 1.25rem;
font-weight: bold;
color: ${({theme}) => theme.colors.text};
border-radius: ${({theme}) => theme.borderRadius.default} ${({theme}) => theme.borderRadius.default} 0 0;
padding: 12px 24px;
&.active {
color: #fff;
background: ${({theme}) => theme.colors.primary};
}
}
}
.tab-content {
padding: 24px 0;
}
`;
export { Tab, Tabs };
// store / toastStore.ts
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
import useToastStore from '@/store/toastStore'
export const useToast = () => {
const showToast = useToastStore((state) => state.addToast);
return { showToast };
};
// components / common / toast / Toast.tsx
import { ToastItem } from '@/store/toastStore';
import styled from 'styled-components';
import { FaPlus, FaBan, FaInfoCircle } from "react-icons/fa";
const Toast = ({ id, message, type } : ToastItem) => {
const handleRemoveToast = () => {
}
return (
<ToastStyle>
<p>
{ type === 'info' && <FaInfoCircle /> }
{ type === 'error' && <FaBan /> }
{ message }
</p>
<button onClick={handleRemoveToast}>
<FaPlus />
</button>
</ToastStyle>
);
}
const ToastStyle = styled.div`
background-color: ${({theme}) => theme.colors.background};
padding: 12px;
border-radius: ${({theme}) => theme.borderRadius.default};
display: flex;
justify-content: space-between;
align-items: start;
gap: 24px;
p {
color: ${({theme}) => theme.colors.text};
line-height: 1;
margin: 0;
flex: 1;
display: flex;
align-items: end;
gap: 4px;
}
button {
background-color: transparent;
border: none;
cursor: pointer;
padding: 0;
margin: 0;
svg {
transform: rotate(45deg);
}
}
`;
export default Toast;
// hooks / useBook.ts
... 생략 ...
const { showToast } = useToast();
const likeToggle = () => {
// 권한 확인
if(!isloggedIn) {
showAlert('로그인이 필요합니다.');
navigate('/login');
return;
}
if(!book) return;
if(book.liked) {
// 라이크 상태 -> 언라이크 실행
unlikeBook(book.id).then(() => {
setBook({
...book,
liked: false,
likes: book.likes - 1,
})
showToast("좋아요가 취소되었습니다")
})
} else {
//언라이크 상태 -> 라이크 실행
likeBook(book.id).then(()=>{
// 성공 처리
setBook({
...book,
liked: true,
likes: book.likes + 1,
})
showToast("좋아요가 성공했습니다");
})
}
};
... 생략 ...
// components / common / toast / ToastContainer.tsx
import useToastStore from '@/store/toastStore';
import styled from 'styled-components';
import Toast from './Toast';
const ToastContainer = () => {
const toasts = useToastStore((state) => state.toasts);
return (
<ToastContainerStyle>
{
toasts.map((toast) => (
<Toast key={toast.id} id={toast.id} message={toast.message}
type={toast.type}/>
))
}
</ToastContainerStyle>
);
}
const ToastContainerStyle = styled.div`
position: fixed;
top: 32px;
right: 24px;
z-index: 1000;
display: flex;
flex-direction: column;
gap: 12px;
`;
export default ToastContainer;
// App.tsx
... 생략 ...
function App() {
return(
<QueryClientProvider client={queryClient}>
<BookStoreThemeProvider>
<RouterProvider router={router} />
<ToastContainer />
</BookStoreThemeProvider>
</QueryClientProvider>
);
}
export default App;
// components / common / toast
import useToastStore, { ToastItem } from '@/store/toastStore';
import styled from 'styled-components';
import { FaPlus, FaBan, FaInfoCircle } from "react-icons/fa";
import { useEffect, useState } from 'react';
export const TOAST_REMOVE_DELAY = 3000;
const Toast = ({ id, message, type } : ToastItem) => {
const removeToast = useToastStore((state) => state.removeToast);
const [isFadingOut, setIsFadingOut] = useState(false);
const handleRemoveToast = () => {
setIsFadingOut(true);
}
useEffect(()=>{
const timer = setTimeout(() => {
// 삭제
// removeToast(id);
handleRemoveToast();
}, TOAST_REMOVE_DELAY);
return () => clearTimeout(timer);
}, []);
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>
);
}
const ToastStyle = styled.div`
@keyframes fade-in {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
@keyframes fade-out {
from {
opacity: 1;
}
to {
opacity: 0;
}
}
&.fade-in {
animation: fade-in 0.3s ease-in-out forwards;
}
&.fade-out {
animation: fade-out 0.3s ease-in-out forwards;
}
background-color: ${({theme}) => theme.colors.background};
padding: 12px;
border-radius: ${({theme}) => theme.borderRadius.default};
display: flex;
justify-content: space-between;
align-items: start;
gap: 24px;
opacity: 0;
transition: all 0.3s ease-in-out;
p {
color: ${({theme}) => theme.colors.text};
line-height: 1;
margin: 0;
flex: 1;
display: flex;
align-items: end;
gap: 4px;
}
button {
background-color: transparent;
border: none;
cursor: pointer;
padding: 0;
margin: 0;
svg {
transform: rotate(45deg);
}
}
`;
export default Toast;
// hooks / useTimeout.ts
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;
// components / common / toast
... 생략 ...
const handleRemoveToast = () => {
setIsFadingOut(true);
}
useTimeout(() => {
handleRemoveToast();
}, TOAST_REMOVE_DELAY);
const handleAnimationEnd = () => {
if(isFadingOut) {
removeToast(id);
}
}
// pages / BookDetail.tsx
... 생략 ...
const [isImgOpen, setIsImgOpen] = useState(false);
if(!book) return null;
return (
<BookDetailStyle>
<header className="header">
<div className="img" onClick={()=>setIsImgOpen(true)}>
<img src={getImgSrc(book.img)} alt={book.title} />
</div>
<Modal isOpen={isImgOpen} onClose={()=>setIsImgOpen(false)}>
<img src={getImgSrc(book.img)} alt={book.title} />
</Modal>
... 생략 ...
// components / common / Modal.tsx
import styled from 'styled-components';
import { FaPlus } from "react-icons/fa";
import React, { useEffect, useRef, useState } from 'react';
import { createPortal } from "react-dom";
interface Props {
children: React.ReactNode;
isOpen: boolean;
onClose: () => void;
}
const Modal = ({children, isOpen, onClose }: Props) => {
const [isFadingout, setIsFadingout] = useState<boolean>(false);
const modalRef = useRef<HTMLDivElement | null>(null);
const handleClose = () => {
setIsFadingout(true);
// onClose();
}
const handleOverlayClick = (e:React.MouseEvent) => {
if(modalRef.current && !modalRef.current.contains(e.target as Node)) {
handleClose();
}
}
const handleKeydown = (e:KeyboardEvent) => {
if(e.key === "Escape") {
handleClose();
}
}
const handleAnimationEnd = () => {
if(isFadingout) {
onClose();
setIsFadingout(false);
}
}
useEffect(()=>{
if(isOpen) {
window.addEventListener("keydown", handleKeydown);
} else {
window.removeEventListener("keydown", handleKeydown);
}
return () => {
window.removeEventListener("keydown", handleKeydown);
}
}, [isOpen]);
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-contents">{children}</div>
<button className="modal-close" onClick={handleClose}>
<FaPlus />
</button>
</div>
</ModalStyle>, document.body);
}
const ModalStyle = styled.div`
@key-frames fade-in {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
@key-frames fade-out {
from {
opacity: 1;
}
to {
opacity: 0;
}
}
&.fade-in {
animation: fade-in 0.3s ease-in-out forwards;
}
&.fade-out {
animation: fade-out 0.3s ease-in-out forwards;
}
position: fixed;
top: 0;
left: 0;
width: 100vw;
height: 100vh;
z-index: 1000;
background-color: rgba(0, 0, 0, 0.6);
.modal-body {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
padding: 56px 32px 32px;
border-radius: ${({theme}) => theme.borderRadius.default};
box-shadow: 0 0 20px rgba(0, 0, 0, 0.5);
background-color: #fff;
max-width: 80%;
}
.modal-close {
border: none;
background-color: transparent;
cursor: pointer;
position: absolute;
top: 0;
right: 0;
padding: 12px;
svg {
width: 20px;
height: 20px;
transform: rotate(45deg);
}
}
`;
export default Modal;
// hooks / useBooksInfinite.ts
import { useLocation } from 'react-router-dom'
import { fetchBooks } from '../api/books.api';
import { QUERYSTRING } from '../constants/querystring';
import { LIMIT } from '../constants/pagination';
import { useInfiniteQuery } from 'react-query';
export const useBooksInfinite = () => {
const location = useLocation();
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 currentPage = pageParam;
return fetchBooks({
category_id,
news,
limit,
currentPage
})
}
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,
pagination,
isEmpty,
isBooksLoading : isFetching,
fetchNextPage,
hasNextPage
} ;
};
// 이전 useBooks의 data 형태
/*
결과 = {
books, pagination
}
*/
// 지금의 data 형태
/*
결과 = {
pages = [
{books, pagination},
{books, pagination},
{books, pagination}
]
}
*/
// pages / Books.tsx
... 생략 ...
const { books, pagination, isEmpty, isBooksLoading,
fetchNextPage, hasNextPage } = useBooksInfinite();
<BooksList books={books} />
{/* <Pagination pagination={pagination} /> */}
<div className="more">
<Button size="medium" scheme="normal" onClick={() => {
fetchNextPage()
}} disabled={!hasNextPage}>{hasNextPage ? "더보기" : "마지막 페이지"}</Button>
</div>
</BooksStyle>
</>
)
}
... 생략 ...
// Books.tsx
... 생략 ...
const Books = () => {
const { books, pagination, isEmpty, isBooksLoading, fetchNextPage, hasNextPage } = useBooksInfinite();
const moreRef = useRef(null);
useEffect(()=>{
const observer = new IntersectionObserver((entries)=> {
entries.forEach((entry)=> {
if(entry.isIntersecting) {
loadMore();
observer.unobserve(entry.target);
}
})
})
if(moreRef.current) {
observer.observe(moreRef.current);
}
return () => observer.disconnect();
}, [books]);
const loadMore = () => {
if(!hasNextPage) return;
fetchNextPage();
}
... 생략 ...
<BooksList books={books} />
{/* <Pagination pagination={pagination} /> */}
<div className="more" ref={moreRef}>
<Button size="medium" scheme="normal" onClick={() => {
fetchNextPage()
}} disabled={!hasNextPage}>{hasNextPage ? "더보기" : "마지막 페이지"}</Button>
</div>
</BooksStyle>
</>
)
}
... 생략 ...
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);
const target = targetRef.current;
if(targetRef.current) {
observer.observe(targetRef.current);
}
return () => {
if(target) {
observer.unobserve(target);
}
}
});
return targetRef;
};