๐ ๋๋์ด 2๋ฌ(?) ์ ๋ ๊ธด ๊ธฐ๊ฐ ๋์ ์งํํ๋ ํ๋ก์ ํธ๊ฐ ์ง์ง ๋์ ๋ฐ๋ผ๋ณด๊ณ ์๋ ๋๋!
๐ฅณ ์ ๋ฆฌํด์ ํ๋ฒ์ ์ฌ๋ฆฌ๋ ค๋ค๊ฐ ์ถํ ๊ธฐ๋ (?)์ผ๋ก ์ค๋ ๋ฐ๋ํ๊ฒ ๋๋ธ react-infinite-scroll+ useInfiniteQuery๋ฌดํ ์คํฌ๋กค ๊ตฌํ์ ๋ํด ๊ธฐ๋กํ๊ธฐ๋ก ํ๋ค.
โ ๏ธ ํ๋ก์ ํธ ๋ผ์ด๋ธ๋ฌ๋ฆฌ๋ค ๋ฒ์ ผ
- "@tanstack/react-query": "^5.37.1"
- "react": "^18.2.0",
- "react-infinite-scroll-component": "^6.1.0"
๐ ๊นํ๋ธ์์ ๋ ํฌ์งํฐ๋ฆฌ ๊ตฌ๊ฒฝํ๋ฌ ๊ฐ๊ธฐ
์ฒซ ์๋
์๋๋ ์ข ๋ณต์กํ ๋ฐฉ์์ผ๋ก ๊ตฌํ์ ํ์๋ค.
"react-intersection-observer" ๋ผ์ด๋ธ๋ฌ๋ฆฌ๋ฅผ ํ์ฉํด ๋ฆฌ์คํธ ๋ง์ง๋ง ์์ดํ ์ ref๋ฅผ ์ง์ ํ๊ณ ,
์ด ref๊ฐ ๋ทฐํฌํธ์ ๋ณด์ด๊ณ hasNextPage===true ์ผ ๊ฒฝ์ฐ useEffect๋ด๋ถ์์ fetchNextPage()์ ํ๋๋ก ํ์๋ค.
๊ทผ๋ฐ ์ด ๋ฐฉ์์ ์ฝ๋๋ฅผ ์์ฒญ ๋ณต์กํ๊ฒ ํ๋ค.
- Props Drilling ์ด์
๊ฒ์ ํ์ด์ง์ ๋ฐ์ดํฐ ํ๋ฆ์ ๋ค์๊ณผ ๊ฐ์๋ค.
[ ๊ฒ์ ํ์ด์ง์ Tab ] => [ Tab ์ข ๋ฅ์ ๋ฐ๋ฅธ Tab์ปดํฌ๋ํธ ] => [ Tab ์ปดํฌ๋ํธ์ ๋ฆฌ์คํธ ์ปดํฌ๋ํธ ] => [ ๋ฆฌ์คํธ ์ปดํฌ๋ํธ์ ์นด๋ ์ปดํฌ๋ํธ ]
ref๊ฐ ๋ทฐํฌํธ์ ๋ณด์ด๋์ง ํ์ธ: innerRef ํ๋กญ์ค๋ ๋ฐ์ดํฐ๋ฅผ ํธ์ถํ๋ [ Tab ์ข ๋ฅ์ ๋ฐ๋ฅธ Tab์ปดํฌ๋ํธ ] ์์๋ถํฐ ๋ง์ง๋ง [ ๋ฆฌ์คํธ ์ปดํฌ๋ํธ์ ์นด๋ ์ปดํฌ๋ํธ ]์์ ์ ๋ฌ์ ๋ฐ๋ณต. ์ค๊ฐ์ ๋ฆฌ์คํธ ์ปดํฌ๋ํธ๋ map์ ๋๋ฉด์ ๋ง์ง๋ง ์ธ๋ฑ์ค์๋ง innerRef ํ๋กญ์ค๋ฅผ [ ๋ฆฌ์คํธ ์ปดํฌ๋ํธ์ ์นด๋ ์ปดํฌ๋ํธ ]์ ์ ๋ฌ.
hasNextPage===true ์ผ ๊ฒฝ์ฐ : useInfiniteQueryํ ์ผ๋ก ๊ฐํธํ๊ฒ ๋ค์ ํ์ด์ง๊ฐ ์กด์ฌํ๋์ง ํ์ธ.
fetchNextPage() : useInfiniteQueryํ ์ผ๋ก ๋ค์ ํ์ด์ง fetch.
๐ค ๋ชฉ์ ์ "ํ์ฌ๊น์ง ๋ถ๋ฌ์จ ๊ฒ์๋ฌผ์ ๋ค ๋ดค๋ค๋ฉด ๋ค์ํ์ด์ง๊ฐ ์์ผ๋ฉด ๊ฒ์๋ฌผ์ ๊ฐ์ ธ์ค๊ธฐ"์ธ๋ฐ "ํ์ฌ๊น์ง ๋ถ๋ฌ์จ ๊ฒ์๋ฌผ์ ๋ค ๋ดค๋์ง"๋ฅผ ์ฒ๋ฆฌํ๋ ๊ณผ์ ์ด ๋๋ฌด ๋ณต์กํด์ก๋ค.
- ์ฝ๋๊ฐ ๋ณต์กํด์ง
ํ๋์ ๊ฒ์ ํญ๋ง ํด๋ ์ ๋ ๊ฒ ๋ง์ ๊ณผ์ ์ ๊ฑฐ์ณ์ผ ํ๋ค. ์ฝ๋๋๋ ๋ถํ์ํ๊ฒ ๋ง์๋ค.
์ฌ๊ธฐ๊น์ง๋ ๋ฌธ์ ๋ฐ, ํญ์ด artist/albums/tracks/playlist๋ก ๊ฐ๊ฐ ๋ฌ๋๊ณ ,
๊ฐ๊ฐ์ ํญ๋ ๋ณด์ฌ์ฃผ๋ ์นด๋์ ๋ด์ฉ์ด ์กฐ๊ธ์ฉ ๋ฌ๋ผ์ง๋ฏ๋ก ์ฌ์ฌ์ฉํ๋๋ฐ ํ๊ณ๊ฐ ์์๋ค.
์ฝ๋๋ฅผ ๊ฐ๋จํ ์ดํด๋ณด์.
๋ค์์ ๊ณตํต์ผ๋ก ์ฌ์ฉํ ๋ฌดํ ์คํฌ๋กค ๋ฆฌ์คํธ ์ปดํฌ๋ํธ๋ค.
// ๋ฌดํ ์คํฌ๋กค ๋ฆฌ์คํธ
const InfiniteList = ({
tabItem,
currentTabPagingInfo,
TabList, // <AlbumList/>, <ArtistList/> ๋ฑ ๊ฐ๊ฐ์ ํญ์ ํด๋นํ๋ ์นด๋๋ค map์ผ๋ก ๋๋ ค์ฃผ๋ ์ปดํฌ๋ํธ
currentTab,
}: InfiniteListProps) => {
const { data, fetchNextPage, hasNextPage, isFetchingNextPage } =
useInfiniteSearchList(currentTabPagingInfo?.href);
const { ref, inView } = useInView();
// ref๊ฐ ๋ทฐํฌํธ์ ๋ณด์ด๊ณ , ๋ค์ ํ์ด์ง๊ฐ ์๋ค๋ฉด ๋ค์ ํ์ด์ง ๊ฐ์ ธ์ค๊ธฐ
useEffect(() => {
if (inView && hasNextPage) {
fetchNextPage();
}
}, [inView, hasNextPage, fetchNextPage]);
//...์๋ต
// ๊ทธ๋ ค์ค ๋ฆฌ์คํธ
const content = data?.pages.map(page => (
<TabList
innerRef={ref}
key={page?.[currentTab]?.href}
tabItem={page?.[currentTab].items}
/>
));
if (!data) {
return <TabList tabItem={tabItem} />;
}
return (
<>
{content} // ์ด์ ๋ด์ฉ์ ์ ์ง
{isFetchingNextPage ? <>{InfiniteCardSkeleton}</> : null} // ๋ก๋์ค์ด๋ผ๋ฉด ์ค์ผ๋ ํค ๋ณด์ด๊ธฐ
</>
);
};
InfiniteList.Skeleton = InfiniteCardSkeleton;
export default InfiniteList;
์ฌ๊ธฐ์ ์๋๋ก๋ Card ์ปดํฌ๋ํธ์ innerRef๋ฅผ ์ ๋ฌํด์ค์ผ ํ๊ณ ,
const TrackItem = ({ item, artistImgUrls, innerRef }: TrackItemProps) => {
const artistInfo = useMultiProfileImg({ item, artistImgUrls });
const [validTrackArtistInfo, setValidTrackArtistInfo] = useState(() =>
artistInfo?.every(item => item.img !== undefined),
@@ -33,6 +41,7 @@ const TrackItem = ({
<Card
title={item?.name}
contextUri={item?.uri}
innerRef={innerRef} // ref ์ถ๊ฐ
title_tag={`${item?.album?.name} #${item?.track_number}`}
topContent={
item?.album?.images ? (
// ์๋ต
์๋ก๋ ๋ฆฌ์คํธ ์ปดํฌ๋ํธ์์,
const TrackList = ({ tabItem, innerRef }: TrackListProps) => {
const artistImgs = useMultiProfileMap({ tabItem });
return tabItem.map((item, index) =>
index === tabItem.length - 1 ? ( // ๋ง์ง๋ง ์นด๋์๋ง innerRef์ถ๊ฐํ๊ธฐ
<TrackItem
item={item}
key={item.id}
innerRef={innerRef}
artistImgUrls={artistImgs}
/>
) : (
<TrackItem item={item} key={item.id} artistImgUrls={artistImgs} />
),
);
};
// ์
๋ต
๊ทธ๋ฆฌ๊ณ ์ต์ด ๋ฐ์ดํฐ ํธ์ถ ์ฅ์์ธ ๊ฒ์ ํ์ด์ง์์๋ถํฐ ์ ๋ฌ์ ๋ฐ์์ผ ํ๋ค.
//...์๋ต
return (
<InfiniteList
tabItem={currentTabItem}
currentTab={currentTab.tab as keyof SearchResult}
currentTabPagingInfo={currentTabPagingInfo}
TabList={currentTab.component}
/>
);
์ฝ๋๋๋ ๊ทธ๋ ๊ณ , ๊ตฌ์กฐ๋ ๊ทธ๋ ๊ณ , DRY์์น๋ ์ ์ง์ผ์ง๊ณ , ์ฑ๋ฅ์๋ ๋ฌธ์ ๊ฐ ์์ ์๋ฐ์ ์๋ค.
๐ ๋์ฐํ ๊ฑด ํญ์ด 4๊ฐ์ด๋ฏ๋ก ๋ฆฌ์คํธ map ๋๋ ๋จ๊ณ์ ๋ฆฌ์คํธ ์์ดํ
์ ์ฝ๋๋ x4๋ฅผ ํด์ค์ผ ํ๋ค.
๐ฉ๐ป๐ป ๊ตฌํ
1. ์ค์น
yarn add react-infinite-scroll-component
2. ์ฒดํฌ๋ฆฌ์คํธ
๋ณธ๊ฒฉ์ ์ผ๋ก ๊ตฌํํ๊ธฐ ์ ์ ์ฒดํฌ๋ฆฌ์คํธ๋ฅผ ๋ณด๊ณ ๊ฐ์. ์์ ์ฝ๋๋ฅผ ํ ๋๋ก ๋ณด๋ฉด ํ์ํ ๊ฒ๋ค์ ๋ค์๊ณผ ๊ฐ๋ค.
- react-infinite-scroll ํ์ ํ๋
- โ dataLength : ์ ์ฒด ๋ฐ์ดํฐ์, ๋ค์ ๋ฐ์ดํฐ๋ฅผ ๋ ๋ ํ๊ธฐ ์ํด ํ์
- โ next : ๋ค์ ๋ฐ์ดํฐ๋ฅผ ๊ฐ์ ธ์ค๊ธฐ ์ํ ํจ์
- โ loader : ๋ค์ ๋ฐ์ดํฐ ๊ฐ์ ธ์ค๋๋ฐ ๋ก๋ฉ ์ค ๋ณด์ฌ์ค ์์
- โ hasMore : ๋ค์ ๋ฐ์ดํฐ์ ์ ๋ฌด
<InfiniteScroll
dataLength={items.length} //This is important field to render the next data
next={fetchData}
hasMore={true}
loader={<h4>Loading...</h4>}
endMessage={
<p style={{ textAlign: 'center' }}>
<b>Yay! You have seen it all</b>
</p>
}
// below props only if you need pull down functionality
refreshFunction={this.refresh}
pullDownToRefresh
pullDownToRefreshThreshold={50}
pullDownToRefreshContent={
<h3 style={{ textAlign: 'center' }}>↓ Pull down to refresh</h3>
}
releaseToRefreshContent={
<h3 style={{ textAlign: 'center' }}>↑ Release to refresh</h3>
}
>
{items}
</InfiniteScroll>
์์ฃผ ๋ฌํค ํ๊ฒ๋ useInfiniteQuery๋ก ์ฝ๊ฒ ์์ ํ๋๋ค์ ์ฑ์ธ ์ ์๋ค.
3. ๊ตฌํํ๊ธฐ
- @/hooks/query/useInfiniteSearchList.ts
import { getPage } from '@/api/spotify';
import { SCOPE } from '@/components/Search/constants';
import { SearchResult } from '@/models/Spotify';
import { useInfiniteQuery } from '@tanstack/react-query';
import { useSearchParams } from 'react-router-dom';
export const useInfiniteSearchList = (url: string) => {
const [searchParam] = useSearchParams();
// ํ์ฌ ํญ์ url ํ๋์์ ์ฐพ๊ณ , ์๋ค๋ฉด ๋ํดํธ์ธ ์จ๋ฒ ํญ์ผ๋ก ์ค์
const currentScope = (searchParam.get(SCOPE) ??
'albums') as keyof SearchResult;
const res = useInfiniteQuery({
queryKey: useInfiniteSearchList.queryKey(url), // ์ฟผ๋ฆฌํค
initialPageParam: url, // ์ฒซํ์ด์ง ํ๋์ ํธ์ถ ์ ๋งค๊ฐ๋ณ์๋ก ์ ๋ฌํด์ค ๊ฐ
queryFn: ({ pageParam }) => getPage(pageParam), // ํธ์ถ ์ฟผ๋ฆฌ ํจ์
getNextPageParam: lastPage => lastPage?.[currentScope]?.next, // ๋ค์ ํ์ด์ง ํ๋ ๊ตฌํ๊ธฐ
getPreviousPageParam: firstPage => firstPage?.[currentScope]?.previous, //์ด์ ํ์ด์ง ํ๋ ๊ตฌํ๊ธฐ
});
return res;
};
useInfiniteSearchList.queryKey = (keywordUrl: string) => {
return ['search', 'infinite', keywordUrl];
};
// @/api/spotify.ts
/**
* ๊ฒ์๋ฌผ ํ์ด์ง ๊ฐ์ ธ์ค๊ธฐ
*/
export async function getPage(endpoint: string) {
return api
.get(endpoint, {})
.json() as unknown as SearchResult;
}
getNextPageParam๊ณผ getPreviousPageParam์ api ๊ฒฐ๊ณผ์ ๋ฐ๋ผ ๋ค๋ฅด๊ฒ ๊ตฌํด์ผ ํ ์ ์๋ค.
์คํฌํฐํ์ด api๋ ๋ค์๊ณผ ๊ฐ์ด {href:''ํ์ฌ ๋ฐ์ดํฐ url", item:๋ฐ์ดํฐ, next:"๋ค์ ๋ฐ์ดํฐ url", previous: "์ด์ ๋ฐ์ดํฐ url", total: ์ ์ฒด ๋ฐ์ดํฐ ๊ฐ์}๋ก (์ฑ๊ณต ์) ์๋ตํ๋ค.
useInfiniteQuery๋ก ๊ตฌํํ๋ฉด data๋ pages ๋ฐฐ์ด ์ pageParams ๋ฐฐ์ด๋ก ๊ตฌ์ฑ๋๋ค.
์ด์ ์ฒดํฌ๋ฆฌ์คํธ์ ํ์ํ๋ ํ๋๋ค์ useInfiniteSearchList ํ ์ผ๋ก ์ฑ์ธ ์ ์๋ค.
- โ dataLength : pages [ํ์ฌ ํญ]. total
- โ next : ()=> fetchNextPage()
- โ loader : {isFetchingNextPage && <์ค์ผ๋ ํค/>}
- โ hasMore : hasNextPage
InfiniteScroll์ ๊ฐ ํญ์์ ์ฌ์ฉํ ์ ์์ง๋ง, ๊ณตํต์ผ๋ก ๋ฌถ์ด๋ ๋ถ๋ถ์ ๋ฐ๋ก ์ปดํฌ๋ํธ๋ก ๋ง๋ค์ด์ค์ ์ฌ์ฌ์ฉํ ์ ์๋๋ก ํด๋ณด์.
- @/components/Search/SearchResult/TabContent/_shared/InfiniteTab.tsx
import { useInfiniteSearchList } from '@/hooks/query/useInfiniteSearchList';
import { Pagination } from '@/models/Pagination';
import { SearchResult } from '@/models/Spotify';
import InfiniteScroll from 'react-infinite-scroll-component';
import { TabItem, TabList } from '../..';
import Card from '../Card';
import Grid from '../Grid';
const InfiniteTab = ({
TabList, // ํญ์ ์นด๋ ์์ดํ
์ mapํด์ฃผ๋ ์ปดํฌ๋ํธ
tabItem, // ์ฒ์ ํ์ด์ง์ ๋ฐ์ดํฐ
currentTabPagingInfo, // href, next, previous ๋ฑ ํ์ด์ง์ ๊ดํ ์ ๋ณด
tab, // ์ด๋ค ํญ์ธ์ง searchParam 'scope=`${artist,album,playlist,track}`'์ ๋์ผํ ์ ๋ณด
}: {
TabList: TabList;
tabItem: TabItem;
currentTabPagingInfo: Pagination;
tab: keyof SearchResult;
}) => {
const { data, fetchNextPage, hasNextPage, isFetchingNextPage } =
useInfiniteSearchList(currentTabPagingInfo.href);
// useInfiniteSearchList์ data๋
// {page:[{ํ์ฌํญ:items:[์ฐ๋ฆฌ๊ฐ ํ์ํ ๋ฐ์ดํฐ]}]} ํํ๋ก ๋์ด ์๋ค.
// reduce๋ก ์ฐ๋ฆฌ๊ฐ ํ์ํ ๋ฐ์ดํฐ๋ก๋ง ์ด๋ฃจ์ด์ง ๋ฐฐ์ด์ ๋ง๋ค์ด์ค๋ค.
const infiniteItemList = data?.pages.reduce((acc, page) => {
return [...acc, ...page?.[tab].items];
}, [] as TabItem);
return !infiniteItemList ? (
<Grid>
<TabList tabItem={tabItem} /> // ์ฒ์์๋ 1ํ์ด์ง ๋ฐ์ดํฐ๋ฅผ ๋ณด์ฌ์ค๋ค.
</Grid>
) : (
<InfiniteScroll
next={() => fetchNextPage()}
hasMore={hasNextPage}
dataLength={infiniteItemList?.length || 0}
loader={<></>} // block ๋์คํ๋ ์ด ํํ๋๋ฌธ์ธ์ง grid ๋ชจ์์ ์ ์งํ๊ธฐ ์ํด ๋น์๋๊ณ ์๋ ์ถ๊ฐ
>
<Grid>
<TabList tabItem={infiniteItemList} />
{isFetchingNextPage &&
Array.from({ length: 20 }, (_, index) => (
<Card.Skeleton key={index} /> // ์นด๋ ์ค์ผ๋ ํค
))}
</Grid>
</InfiniteScroll>
);
};
export default InfiniteTab;
์ด์ ์ด ์ปดํฌ๋ํธ๋ฅผ ๋ฐ์ดํฐ๋ฅผ ์ฒ์ ํธ์ถํ๋ ์ต์๋จ์ ํ์ด์ง์์ ์ฐ๊ธฐ๋ง ํ๋ฉด ๋๋ค.
- @/components/Search/SearchResult/TabContent/index.tsx
import { useSearchKeyword } from '@/hooks/query/useSearchKeyword';
import { Album } from '@/models/Album';
import { Pagination } from '@/models/Pagination';
import { Playlist } from '@/models/Playlist';
import { Artist } from '@/models/Profile';
import { SearchResult } from '@/models/Spotify';
import { Track } from '@/models/Track';
import { useEffect, useState } from 'react';
import { useSearchParams } from 'react-router-dom';
import { SCOPE, TAB } from '../../constants';
import Grid from './_shared/Grid';
import InfiniteTab from './_shared/InfiniteTab';
import AlbumTab from './AlbumTab';
import ArtistTab from './ArtistTab';
import PlaylistTab from './PlaylistTab';
import TrackTab from './TrackTab';
export type TabItem = Album[] | Artist[] | Track[] | Playlist[];
export type TabList = ({ tabItem }: { tabItem: TabItem }) => JSX.Element[];
const tab = [
{
tab: TAB.ALBUMS,
label: '์จ๋ฒ',
component: AlbumTab, // ํญ ๋ฆฌ์คํธ mapํ ์ปดํฌ๋ํธ
},
{
tab: TAB.ARTISTS,
label: '์ํฐ์คํธ',
component: ArtistTab,
},
{
tab: TAB.TRACKS,
label: 'ํธ๋',
component: TrackTab,
},
{
tab: TAB.PLAYLISTS,
label: 'ํ๋ ์ด๋ฆฌ์คํธ',
component: PlaylistTab,
},
] as { tab: keyof SearchResult; label: string; component: TabList }[];
export default function TabContent() {
const { data, isFetched, isLoading } = useSearchKeyword();
const [searchParam] = useSearchParams();
const [currentTab, setCurrentTab] = useState(tab[0]);
const [currentTabPagingInfo, setCurrentTabPagingInfo] =
useState<Pagination | null>(null);
const [currentTabItem, setCurrentTabItem] = useState<TabItem | null>(null);
useEffect(() => {
const changedTab = tab.filter(
tabItem => searchParam.get(SCOPE) === tabItem.tab,
);
if (isFetched && data != null) {
const { items, ...pageInfo } =
data[(searchParam.get(SCOPE) ?? tab[0].tab) as keyof SearchResult];
setCurrentTabPagingInfo(pageInfo);
setCurrentTabItem(items);
}
if (changedTab.length === 0) {
setCurrentTab(tab[0]);
} else {
setCurrentTab(changedTab[0]);
}
}, [searchParam, data, isFetched]);
if (isLoading || currentTabPagingInfo == null || currentTabItem == null) {
return <Grid.Skeleton />;
}
return (
// ์ฌ์ฉ!
<InfiniteTab
TabList={currentTab.component}
tab={currentTab.tab}
tabItem={currentTabItem}
currentTabPagingInfo={currentTabPagingInfo}
/>
);
}
๐ ์์ฑ ๋ชจ์ต
๐ฉ๐ป๐ป ๋ง์น๋ฉด์
๐ ํค๋งจ ์ (TMI)
์๋๋ ํ์ด์ง ๊ตฌ์กฐ๋ก ๋ง๋ค๋ ค๋ค๊ฐ.. api์ ๋ถ์ ํฉํ์๊ธฐ ๋๋ฌธ์ ๋ฌดํ ์คํฌ๋กค๋ก ๋ณ๊ฒฝ
๋ค์ ํ์ด์ง ๊ฐ์ ธ์ค๊ฒ ํ๋ ค๋ฉด ์ด๋ป๊ฒ ํด์ผ ํ์ง ํค๋งธ์๋ค.์ฒ์์๋ ์ผ์ผ์ด offset+limit์ผ๋ก ๊ตฌํด์ฃผ๋ ค ํ๋ค.
const tab์ component ํ์ ์ด ์กฐ๊ธ์ฉ ๋ฌ๋ผ์ ts๊ฐ ๊ณ์ ๋ถํ..๐ก ๊ฒฐ๊ตญ์ ํ์ ๋จ์ธ์ ํด์คฌ๋๋ฐ ์ด๊ฒ ๋ง๋? ์ถ๊ธฐ๋ ํ๋ค.
๐ซจ ์์ฌ์ด ์
์ ๋ณด์ด๋ ๋ถ๋ถ์ ๋ฆฌ์คํธ ๋ฐ์ดํฐ๋ฅผ ๊ตณ์ด ์ง๋๊ณ ํ์๊ฐ ์๋๋ฐ๋ ๋ฉ๋ชจ๋ฆฌ ๋ญ๋น๋ฅผ ํ๊ณ ์๋ ๊ฒ ๊ฐ๋ค.
๊ทธ๋ฆฌ๊ณ ์๋ก๊ณ ์นจ์ ํ๋ฉด ํด๋น ์คํฌ๋กค ์์น์ ๋ฐ์ดํฐ๋ฅผ ์ ์งํ์ง ๋ชปํ๋ค๋ ์ ์์ ํ๋ฒ ๊ฐ์ ๋ฆฌ์คํธ๋ฅผ ๊ตฌํํด๋ณด๊ณ ์ถ๋ค!
๐ References
https://www.npmjs.com/package/react-infinite-scroll-component
https://tanstack.com/query/latest/docs/framework/react/reference/useInfiniteQuery#useinfinitequery