๐๐ป ์ด๋ฒ์๋ ํ์ด์ง๋ค์ด์
๊ธฐ๋ฅ์ ๊ตฌ์กฐ๋ฅผ ์์ธํ ์ดํด ๋ด
์๋ค.
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.”