[nextjs]SNS Server-51(myapp61+)[Pagenation]ย 

๐Ÿ‘‰๐Ÿป ์ด๋ฒˆ์—๋Š” ํŽ˜์ด์ง€๋„ค์ด์…˜ ๊ธฐ๋Šฅ์˜ ๊ตฌ์กฐ๋ฅผ ์ž์„ธํžˆ ์‚ดํŽด ๋ด…์‹œ๋‹ค.
Now, let’s take a closer look at the structure of the pagination feature.

๐Ÿ‘‰๐Ÿป ์ด์ „ ํฌ์ŠคํŠธ์—์„œ ๋ฐ์ดํ„ฐ๊ฐ€ app/usersui/[username]/page.tsx์—์„œ ์‹œ์ž‘ํ•ด์„œ app/usersui/[username]/_components/themes/themeex/Timeline.tsx๊นŒ์ง€ ์–ด๋–ป๊ฒŒ ํ˜๋Ÿฌ๊ฐ€๋Š”์ง€ ์‚ดํŽด ๋ณด์•˜์Šต๋‹ˆ๋‹ค.
In the previous post, we examined how data flows from app/usersui/[username]/page.tsx down to app/usersui/[username]/_components/themes/themeex/Timeline.tsx.

๐Ÿ‘‰๐Ÿป ์ด ํŽ˜์ด์ง€๋„ค์ด์…˜ ํŒŒํŠธ์—์„œ๋Š” ์ดˆ๊ธฐ ๋ฐ์ดํ„ฐ๊ฐ€ ํ™”๋ฉด์— ํ‘œ์‹œ๋œ ํ›„์— ์Šคํฌ๋กค์„ ๋‚ด๋ฆฌ๋ฉด ์–ด๋–ป๊ฒŒ ๋ฐ์ดํ„ฐ๊ฐ€ ๋” ์ถ”๊ฐ€๋˜๋Š” ๊ฑด์ง€ ์ž์„ธํžˆ ์‚ดํŽด๋ด…๋‹ˆ๋‹ค.
In this pagination section, we take a detailed look at how additional data is loaded when scrolling down after the initial data is displayed on the screen.

๐Ÿ“ ํŽ˜์ด์ง€๋„ค์ด์…˜ ํ๋ฆ„ / Pagination flow

1.๋ผ์šฐํŠธ ์‹คํ–‰ / Execute route
- https://aloy-horizon.duckdns.org/@user1
- or https://aloy-horizon.duckdns.org/usersui/user1
- page.tsx ์‹คํ–‰
- ClientPage.tsx ํŒŒ์ผ ๋กœ๋“œ / file load

2.ClientPage.tsx 
- ์ดˆ๊ธฐ ๋ฐ์ดํ„ฐ๋กœ๋“œ / Initial data load
- ํ…Œ๋งˆ ํ˜ธ์ถœ / Theme Call
- ํ…Œ๋งˆ ๋””๋ ‰ํ† ๋ฆฌ์˜ TimelinList.tsx์— ์ดˆ๊ธฐ ๋ฐ์ดํ„ฐ ์ „๋‹ฌ
Passing initial data to TimelinList.tsx in the theme directory.

3.TimelinList.tsx
- ์ดˆ๊ธฐ๋ฐ์ดํ„ฐ ํƒ€์ž„๋ผ์ธ์— ํ‘œ์‹œ
Display initial data on the timeline
- ์Šคํฌ๋กค ๋‚ด๋ ค์„œ ์ดˆ๊ธฐ๋ฐ์ดํ„ฐ ๋์— ๋„๋‹ฌํ•˜๋ฉด ๋ฐ์ดํ„ฐ ์ถ”๊ฐ€ ํ˜ธ์ถœ 
Call for additional data when scrolling down and reaching the end of the initial data.
- ํ˜ธ์ถœ๋œ ๋ฐ์ดํ„ฐ๋ฅผ ์ดˆ๊ธฐ๋ฐ์ดํ„ฐ ๋’ค์— ๋ถ™์ด๊ธฐ
Append the fetched data after the initial data.

๐Ÿ“ ์ดˆ๊ธฐ๋ฐ์ดํ„ฐ ๋กœ๋“œ – ClientPage.tsx

1)username์ด ๋ฐ”๋€Œ๋ฉด ์ดˆ๊ธฐ ๋ฐ์ดํ„ฐ๊ฐ€ ๋กœ๋“œ๊ฐ€ ์‹œ์ž‘๋ฉ๋‹ˆ๋‹ค.
When the username changes, the initial data begins to load.

useEffect(() => { ... ... }, [username]);

2)ํƒ€์ž„๋ผ์ธ ๋ถ„๊ธฐ๋Š” ๋กœ๊ทธ์ธํ•œ ๊ฒฝ์šฐ์™€ ๋กœ๊ทธ์ธ ํ•˜์ง€ ์•Š์€ ๊ฒฝ์šฐ ๋ณด์—ฌ์ง€๋Š” ๋ฐ์ดํ„ฐ๊ฐ€ ๋‹ค๋ฅด๊ธฐ ๋•Œ๋ฌธ์— ํƒ€์ž„๋ผ์ธ ๋ถ„๊ธฐ๋ฅผ ์‚ฌ์šฉํ•ฉ๋‹ˆ๋‹ค.
Timeline branching is used because the data displayed differs depending on whether the user is logged in or not.

# ๋กœ๊ทธ์ธ ํ•œ ๊ฒฝ์šฐ / If logged in
# ์ฟ ํ‚ค ํฌํ•จํ•ด์„œ ์ „์†ก / Send with cookies
- credentials: 'include' 
const homeRes = await fetch(`/api/v1/timelines/home?limit=20`, { credentials: 'include' });

# ๋กœ๊ทธ์ธ ํ•˜์ง€ ์•Š์€ ๊ฒฝ์šฐ / If not logged in
const pubRes = await fetch(`/api/v1/timelines/public?local=true&limit=20`);

3) ๋ฐ์ดํ„ฐ ์ „๋‹ฌ๊ณผ์ •์€ [nextjs]SNS Server-51(myapp61+)[Active theme] ์ด ํฌ์ŠคํŠธ๋ฅผ ์ฐธ์กฐํ•˜์„ธ์š”
Please refer to the post “[nextjs]SNS Server-51(myapp61+)[Active theme]” for the data transmission process.

... ...
const [loading, setLoading] = useState(true);
... ...  
// โœ… myapp61 
  // - ์ฒซ ๋กœ๋”ฉ: ๋กœ๊ทธ์ธ=home, ๋น„๋กœ๊ทธ์ธ=public local
  // - Initial load: Logged in = Home; Not logged in = Public Local
  useEffect(() => {
... ...

    // 2. ํƒ€์ž„๋ผ์ธ ๋ถ„๊ธฐ / Timeline branching
    if (me) {
      // ๋กœ๊ทธ์ธ O -> ๋‚ด home ํƒ€์ž„๋ผ์ธ
      // Logged in -> My home timeline
      console.log('[timeline] ๋กœ๊ทธ์ธ ์œ ์ € home ๋กœ๋“œ / Login user home load');
      try {
        const homeRes = await fetch(`/api/v1/timelines/home?limit=20`, { credentials: 'include' });

        const json = await homeRes.json();
        let arr = Array.isArray(json)? json : json.data || [];

        setTimeline(arr);
      } catch (e) {
        console.error(e);
      }
    } else {
      // ๋กœ๊ทธ์ธ X -> URL์˜ @username ๊ธ€ ๋งŒ
      // No login -> Only the @username post in the URL
      console.log('[timeline] ๋น„๋กœ๊ทธ์ธ/not log in, param username:', username, '๊ธ€๋งŒ/only posts');
      const pubRes = await fetch(`/api/v1/timelines/public?local=true&limit=20`);
      const json = await pubRes.json();
      const arr = Array.isArray(json)? json : json.data || [];

      // @user1 ํŽ˜์ด์ง€๋ฉด user1 ๊ธ€๋งŒ ํ•„ํ„ฐ
      // If on the user1 page, filter to show only user1's posts.
      const filtered = username? arr.filter((p:any) => p.account?.username === username || p.account?.acct?.startsWith(username)) : arr;
      console.log('[timeline] ๋น„๋กœ๊ทธ์ธ filtered:', filtered.length);
      setTimeline(filtered);
    }
    setLoading(false);
  };
  init();
}, [username]);

๐Ÿ“ ์ „๋‹ฌ ๋ฐ›์€์ดˆ๊ธฐ ๋ฐ์ดํ„ฐ ํ๋ฆ„ / Initial data flow received

โœ”๏ธ ClientPage.tsx์—์„œ ์ „๋‹ฌ๋ฐ›์€ ์ดˆ๊ธฐ๋ฐ์ดํ„ฐ๋Š” TimelineList.tsx์˜ useCursorPaginationํ›…์— ํ•œ๋ฒˆ ๋“ค์–ด๊ฐ”๋‹ค๊ฐ€ ๋‚˜์˜ต๋‹ˆ๋‹ค.
The initial data received in ClientPage.tsx passes through the useCursorPagination hook in TimelineList.tsx before being returned.

1.์ „๋‹ฌ ๋ฐ›์€ timeline๋ฐ์ดํ„ฐ๋ฅผ ์•„๋ž˜์˜ ํ›…์— ๋„ฃ๊ธฐ
Pass the received timeline data into the hook below.
- TimelineList.sx

... ...
  const {
    flatData,
    fetchNextPage,
    hasNextPage,
    isFetchingNextPage,
  } = useCursorPagination({
     initialData: initialTimeline, // 20๊ฐœ ๋„ฃ์Œ / put in 20
     fetcher:...
   })


2.useCursorPagination ํ•จ์ˆ˜ ๋‚ด๋ถ€
Inside the `useCursorPagination` function
- app/hook/userCursorPagination.tsx

- 2์ฐจ์› ๋ฐฐ์—ด๋กœ ๋ฐ์ดํ„ฐ page์— ์ €์žฅ
Store data in a 2D array on the page.
[[initialData1][initialData2]... ...]

- ์ด page ๋ฐฐ์—ดํ•˜๋‚˜ ๋งˆ๋‹ค 20๊ฐœ์”ฉ ๋ฐ์ดํ„ฐ ์ €์žฅ๋จ. 
Data for 20 items is stored in each element of this page array.

const [pages, setPages] = useState<T[][]>(initialData.length? [initialData] : []);

- flatData๋กœ ๋ฆฌํ„ด / Return as flatData.
- flat()ํ•จ์ˆ˜๋Š” 2์ฐจ์› ๋ฐฐ์—ด์„ 1์ฐจ์› ๋ฐฐ์—ด๋กœ๋กœ ๋ณ€๊ฒฝํ•จ(๊ทธ๋ƒฅ ๋ฐ์ดํ„ฐ ๋‚˜์—ด)
The `flat()` function converts a 2D array into a 1D array (simply listing the data).
- filter(Boolean)
 fetch์‹คํŒจ๋กœ null,undefined,false๊ฐ€ ์žˆ์œผ๋ฉด ์ œ๊ฑฐ
 Remove null, undefined, or false values โ€‹โ€‹resulting from a fetch failure.
... ...   

const flatData = pages.flat().filter(Boolean) as T[]; 

  return {
    flatData,
    pages: pages.map(data => ({ data })),
    fetchNextPage,
    hasNextPage,
    isFetchingNextPage,
    isLoading,
  };

3.UI์— ๋ฐ์ดํ„ฐ ํ‘œ์‹œ / Display data on the UI
- ๋ฐ์ดํ„ฐ ์ค‘๋ณต ์ œ๊ฑฐ, uniquekey๋ฅผ ์œ„ํ•ด ์ธ๋ฑ์Šค ์‚ฌ์šฉ
Using indexes for data deduplication and unique keys.

... ...
  const flat = (flatData || []).flat().filter(Boolean) as any[];
  const data = flat.length > 0 ? flat : initialTimeline.filter((p:any) => p && p.id);
... ...
return (
... ...
 {data.filter((p:any) => p && p.id).map((p: any, idx: number) => {... ...}
... ...
);

๐Ÿ“ ํŽ˜์ด์ง€๋„ค์ด์…˜ / Pagenation

โœ”๏ธ ์Šคํฌ๋กค ๊ฐ์ง€ / Scroll detection

1) ํŽ˜์ด์ง€๋ฅผ ์Šคํฌ๋กคํ•ด์„œ ๋‚ด๋ฆฌ๋‹ค๊ฐ€ ๋ฌดํ•œ ์Šคํฌ๋กค ํŠธ๋ฆฌ๊ฑฐ ์œ„์น˜๊นŒ์ง€ 30% (threshhold: 0.3)๋‚จ์•˜์„๋•Œ fetchNextPage()ํ•จ์ˆ˜ ์‹คํ–‰
Execute the fetchNextPage() function when scrolling down the page and reaching a point 30% (threshold: 0.3) away from the infinite scroll trigger position.

2)<div ref={loaderRef} ์ด๋ ‡๊ฒŒ ๋ฌดํ•œ์Šคํฌ๋กค ์˜ต์ €๋ฒ„๊ฐ€ ํŠธ๋ฆฌ๊ฑฐ์˜ ์œ„์น˜๋ฅผ ๊ฐ์‹œํ•จ.
With <div ref={loaderRef}>, the infinite scroll observer monitors the position of the trigger.

1.๋ฌดํ•œ์Šคํฌ๋กค ์˜ต์ €๋ฒ„ / infinite scroll observer

  const loaderRef = useRef<HTMLDivElement>(null);
  useEffect(() => {
    const el = loaderRef.current;
    if (!el ||!hasNextPage) return;
    const observer = new IntersectionObserver(
      ([entry]) => {
        if (entry.isIntersecting &&!isFetchingNextPage) fetchNextPage();
      },
      { threshold: 0.3 }
    );
    observer.observe(el);
    return () => observer.disconnect();
  }, [hasNextPage, isFetchingNextPage, fetchNextPage]);

2. ๋ฌดํ•œ ์Šคํฌ๋กค ํŠธ๋ฆฌ๊ฑฐ / Infinite scroll trigger

      <div ref={loaderRef} className="h-10 flex items-center justify-center text-sm text-[#999]">
        {isFetchingNextPage && <span>๋ถˆ๋Ÿฌ์˜ค๋Š” ์ค‘...</span>}
        {!hasNextPage && data.length > 0 && <span className="text-xs">๋ / No more posts</span>}
      </div>

โœ”๏ธ ๋ฐ์ดํ„ฐ ์ถ”๊ฐ€ / Add Data

— TimelinList.tsx์˜ fetcher์ฝœ๋ฐฑํ•จ์ˆ˜(์‹ค์ œ๋กœ ๋ฐ์ดํ„ฐ ํ˜ธ์ถœํ•˜๋Š” ํ•จ์ˆ˜)๋ฅผ useCursorPaginationํ•จ์ˆ˜ ๋‚ด์—์„œ ์‹คํ–‰
Execute the fetcher callback function (the function that actually fetches the data) from TimelinList.tsx within the useCursorPagination function.

— useCursorPaginationํ•จ์ˆ˜๋‚ด์˜ fetchNextPageํ•จ์ˆ˜๋‚ด์—์„œ ๊ธฐ์กด๋ฐ์ดํ„ฐ์—์‹ ๊ทœ ๋ฐ์ดํ„ฐ๋ฅผ์ถ”๊ฐ€
Add new data to existing data within the fetchNextPage function inside the useCursorPagination function

— TimelineList.tsx์—์„œ ์‚ฌ์šฉ๊ฐ€๋Šฅํ•˜๋„๋ก flatData๋ณ€์ˆ˜๋กœ ๋ฆฌํ„ด,fetchNextPageํ•จ์ˆ˜๋„ ์‚ฌ์šฉ๊ฐ€๋Šฅํ•˜๋„๋ก ๋ฆฌํ„ด
Return the flatData variable so it can be used in TimelineList.tsx, and also return the fetchNextPage function so it is available for use.

1)์ฝœ๋ฐฑํ•จ์ˆ˜ fetcher๊ฐ€ useCursorPaginationํ•จ์ˆ˜ ๋‚ด์—์„œ ์‹คํ–‰
The callback function fetcher is executed within the useCursorPagination function.

2)Set{id1, id2, …}์€ ์ค‘๋ณต์—†๋Š” ์ง‘ํ•ฉ
Set{id1, id2, โ€ฆ} is a set containing no duplicates.

3)setPagesํ•จ์ˆ˜์— return […prev, filtered]; ๋กœ ๊ธฐ์กด๋ฐ์ดํ„ฐ์— ์‹ ๊ทœ ๋ฐ์ดํ„ฐ ์ถ”๊ฐ€
Add new data to the existing data in the setPages function using return [...prev, filtered];.

4)setPagesํ•จ์ˆ˜๋กœ ์ €์žฅ๋œ pages๊ฐ’์„ flatData๋กœ ๋ฆฌํ„ด
Returns the pages value stored by the setPages function as flatData.

5)flatData๋กœ ๋ฆฌํ„ด๋œ ๊ฐ’์„ const flatData = pages.flat().filter(Boolean) as T[]; ์ด๋ ‡๊ฒŒ flatData๋ณ€์ˆ˜์— ์ €์žฅ ํ›„ ๋ฆฌํ„ด
Store the value returned as flatData in the flatData variableโ€”using const flatData = pages.flat().filter(Boolean) as T[];โ€”and then return it.

# 1.์ฝœ๋ฐฑํ•จ์ˆ˜ / Callback function
- TimelineList.tsx
- ์ด ์ฝœ๋ฐฑํ•จ์ˆ˜๊ฐ€ useCursorPagination() ํ›… ์•ˆ์— fetchNextPage()๊ฐ€ ์‹คํ–‰๋จ.
This callback function executes `fetchNextPage()` within the `useCursorPagination()` hook.

  const {
    flatData,
    fetchNextPage,
    hasNextPage,
    isFetchingNextPage,
  } = useCursorPagination({
    //key: ['timeline', tab],
    key: ['timeline',tab, currentUser || 'anone'], // add current user
    initialData: initialTimeline.slice(0,20),  // โœ… myapp61
    fetcher: async (cursor) => { 
... ...

      let url = '';
      if (tab === 'home') {
        if (effectiveUser) {
          url = `/api/v1/timelines/home?${params.toString()}`;
        } else {
          params.set('local', 'true');
          url = `/api/v1/timelines/public?${params.toString()}`;
        }
      } else if (tab === 'local') {
        params.set('local', 'true');
        url = `/api/v1/timelines/public?${params.toString()}`;
      } else {
        url = `/api/v1/timelines/public?${params.toString()}`;
      }
... ...
 const res = await fetch(url, { credentials: 'include' });
 const arr = Array.isArray(json)? json : (json.data || json.statuses || []);
 const withMine = arr.map((p:any) => { ... ...  });

       return {
        data: withMine,
        nextCursor: withMine.length? withMine[withMine.length-1].id : null,
        hasMore: true
      };
  }

# 2.fetchNextPage()ํ•จ์ˆ˜ / fetchNextPage() function
- ์‹ค์ œ ์—ฌ๊ธฐ์„œ ์ฝœ๋ฐฑํ•จ์ˆ˜์ธ fetcher๋ฅผ ์‹คํ–‰ํ•ด์„œ API์—์„œ ๊ฐ€์ ธ์˜จ ๋ฐ์ดํ„ฐ๋ฅผ ๊ธฐ์กด๋ฐ์ดํ„ฐ ๋’ค์— ๋ถ™์ž„
Here, the `fetcher` callback function is actually executed to append the data retrieved from the API to the existing data.

- hooks/useCursorPagination.tsx

... ...
const [pages, setPages] = useState<T[][]>(initialData.length? [initialData] : []);
... ...
const fetchNextPage = useCallback(async () => {
... ...
      const res: any = await fetcher(nextCursorRef.current || undefined);
      const data = Array.isArray(res)? res : res.data || [];
... ...
      setPages(prev => {
        const seen = new Set(prev.flat().map((p:any) => p.id));
        const filtered = data.filter((p:any) => p.id &&!seen.has(p.id));
        if (filtered.length === 0) {
          setHasNextPage(false);
          return prev;
        }
        return [...prev, filtered];
      });
}
... ...

 const flatData = pages.flat().filter(Boolean) as T[]; 

  return {
    flatData,
    pages: pages.map(data => ({ data })),
    fetchNextPage,
    hasNextPage,
    isFetchingNextPage,
    isLoading,
  };

# 3.useCursonpagination() ํ›…์—์„œ ๋ฆฌํ„ด๋œ flatData๋ฅผ ๋‹ค์‹œ TimelineList.tsx flatData์— ์ €์žฅํ•˜๊ณ  jsx๋กœ ํ‘œ์‹œ
Store the `flatData` returned from the `useCursorPagination()` hook into the `flatData` of `TimelineList.tsx` and render it in JSX.

  const {
    flatData,
    fetchNextPage,
    hasNextPage,
    isFetchingNextPage,
  } = useCursorPagination({ ... }

... ...
return (
... ...
{data.filter((p:any) => p && p.id).map((p: any, idx: number) => { ... ... }
... ...
);
๐Ÿ’ก ์ถ”๊ฐ€ ํŽ˜์ด์ง€๊ฐ€ ์žˆ๋Š” ๊ฒฝ์šฐ ์„œ๋ฒ„๋Š” ์•„๋ž˜์™€ ๊ฐ™์€ ํ˜•์‹์œผ๋กœ ํ˜ธ์ถœ ๋จ
If there are additional pages, the server is called in the following format

 GET /api/v1/timelines/home?limit=20&max_id=59d38066-6cc2-4c2b-b06e-710f7293fda6

... ...

GET /api/v1/timelines/home?limit=20&max_id=41b5b137-d56c-4e36-a995-3dbed5ef061c

... ...

GET /api/v1/timelines/home?limit=20&max_id=a864ab3b-a090-4575-a7b1-0bf5c075f73b

โœ”๏ธ ๊ธ€ 50๊ฐœ ์ƒ์„ฑ ๋ฐ ์‚ญ์ œ / Create and delete 50 posts

๐Ÿ’ก
# 1. ๊ธ€ 50๊ฐœ ์ƒ์„ฑ (๋กœ๊ทธ์ธ ์ƒํƒœ์—์„œ) / Generate 50 posts (while logged in)
curl -X POST https://aloy-horizon.duckdns.org/api/test/bulk \
  -H "Content-Type: application/json" \
  -b "refresh_token=YOUR_TOKEN" \
  -d '{"count":50}'

# 2. ์‚ญ์ œ / delete
curl -X DELETE https://aloy-horizon.duckdns.org/api/test/bulk \
  -H "Content-Type: application/json" \
  -b "refresh_token=YOUR_TOKEN" \

๋งˆํƒœ๋ณต์Œ 21์žฅ 22์ ˆ / Matthew 21: 22

“๋„ˆํฌ๊ฐ€ ๊ธฐ๋„ํ•  ๋•Œ์— ๋ฌด์—‡์ด๋“ ์ง€ ๋ฏฟ๊ณ  ๊ตฌํ•˜๋Š” ๊ฒƒ์€ ๋‹ค ๋ฐ›์œผ๋ฆฌ๋ผ ํ•˜์‹œ๋‹ˆ๋ผ”

“And everything you ask in prayer, believing, you will receive.”

Leave a Reply