๋ณธ๋ฌธ ๋ฐ”๋กœ๊ฐ€๊ธฐ

๐Ÿ‘ฉ๐Ÿป‍๐Ÿ’ป dev

[spotify-api] VINYLIFY : ์Šคํฌํ‹ฐํŒŒ์ด apiํ™œ์šฉํ•œ ๊ฒ€์ƒ‰ + ์žฌ์ƒ ํ”„๋กœ์ ํŠธ(n)๊ฒ€์ƒ‰ ๊ฒฐ๊ณผ react-infinite-scroll๊ณผ useInfiniteQuery๋กœ ๋ฌดํ•œ ์Šคํฌ๋กค ๊ตฌํ˜„ํ•˜๊ธฐ

๐Ÿ˜… ๋“œ๋””์–ด 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' }}>&#8595; Pull down to refresh</h3>
  }
  releaseToRefreshContent={
    <h3 style={{ textAlign: 'center' }}>&#8593; 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: ์ „์ฒด ๋ฐ์ดํ„ฐ ๊ฐœ์ˆ˜}๋กœ (์„ฑ๊ณต ์‹œ) ์‘๋‹ตํ•œ๋‹ค. 

tanstack query devtool๋กœ ์‚ดํŽด๋ณธ spotify api ์‘๋‹ต ๊ฒฐ๊ณผ ๋ฐ์ดํ„ฐ ํ˜•ํƒœ

 

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}
    />
  );
}

๐Ÿ€ ์™„์„ฑ ๋ชจ์Šต

react-infinite-scroll-component + useInfiniteQuery

๐Ÿ‘ฉ๐Ÿป‍๐Ÿ’ป ๋งˆ์น˜๋ฉด์„œ

๐Ÿ˜… ํ—ค๋งจ ์  (TMI)
์›๋ž˜๋Š” ํŽ˜์ด์ง• ๊ตฌ์กฐ๋กœ ๋งŒ๋“ค๋ ค๋‹ค๊ฐ€.. api์™€ ๋ถ€์ ํ•ฉํ•˜์˜€๊ธฐ ๋•Œ๋ฌธ์— ๋ฌดํ•œ ์Šคํฌ๋กค๋กœ ๋ณ€๊ฒฝ
๋‹ค์Œ ํŽ˜์ด์ง€ ๊ฐ€์ ธ์˜ค๊ฒŒ ํ•˜๋ ค๋ฉด ์–ด๋–ป๊ฒŒ ํ•ด์•ผ ํ•˜์ง€ ํ—ค๋งธ์—ˆ๋‹ค.  ์ฒ˜์Œ์—๋Š” ์ผ์ผ์ด offset+limit์œผ๋กœ ๊ตฌํ•ด์ฃผ๋ ค ํ–ˆ๋‹ค.
const tab์˜ component ํƒ€์ž…์ด ์กฐ๊ธˆ์”ฉ ๋‹ฌ๋ผ์„œ ts๊ฐ€ ๊ณ„์† ๋ถˆํ‰..๐Ÿ˜ก ๊ฒฐ๊ตญ์€ ํƒ€์ž… ๋‹จ์–ธ์„ ํ•ด์คฌ๋Š”๋ฐ ์ด๊ฒŒ ๋งž๋‚˜? ์‹ถ๊ธฐ๋„ ํ•˜๋‹ค.

๐Ÿซจ ์•„์‰ฌ์šด ์ 
์•ˆ ๋ณด์ด๋Š” ๋ถ€๋ถ„์€ ๋ฆฌ์ŠคํŠธ ๋ฐ์ดํ„ฐ๋ฅผ ๊ตณ์ด ์ง€๋‹ˆ๊ณ  ํ•„์š”๊ฐ€ ์—†๋Š”๋ฐ๋„ ๋ฉ”๋ชจ๋ฆฌ ๋‚ญ๋น„๋ฅผ ํ•˜๊ณ  ์žˆ๋Š” ๊ฒƒ ๊ฐ™๋‹ค.
๊ทธ๋ฆฌ๊ณ  ์ƒˆ๋กœ๊ณ ์นจ์„ ํ•˜๋ฉด ํ•ด๋‹น ์Šคํฌ๋กค ์œ„์น˜์˜ ๋ฐ์ดํ„ฐ๋ฅผ ์œ ์ง€ํ•˜์ง€ ๋ชปํ•œ๋‹ค๋Š” ์ ์—์„œ ํ•œ๋ฒˆ ๊ฐ€์ƒ ๋ฆฌ์ŠคํŠธ๋ฅผ ๊ตฌํ˜„ํ•ด๋ณด๊ณ  ์‹ถ๋‹ค!

 


๐Ÿ“š References

https://www.npmjs.com/package/react-infinite-scroll-component

 

react-infinite-scroll-component

An Infinite Scroll component in react.. Latest version: 6.1.0, last published: 3 years ago. Start using react-infinite-scroll-component in your project by running `npm i react-infinite-scroll-component`. There are 549 other projects in the npm registry usi

www.npmjs.com

https://tanstack.com/query/latest/docs/framework/react/reference/useInfiniteQuery#useinfinitequery

 

useInfiniteQuery | TanStack Query React Docs

Does this replace [Redux, MobX, etc]? react

tanstack.com