Skip to content

Commit ebbdcef

Browse files
Fix(client): 직무 아티클 무한 스크롤 중복 호출 버그 해결 (#298)
* setting: add react-intersection-observer * refactor: useInfiniteScroll 내부 useInView 사용하도록 변경 * feat: 바뀐 useInfiniteScroll interface 적용 * fix: getNextPageParam 실행 조건 수정 * chore: 불필요 코드 제거 * fix(client): pass scroll root directly to useInView in useInfiniteScroll
1 parent e4584aa commit ebbdcef

File tree

8 files changed

+98
-68
lines changed

8 files changed

+98
-68
lines changed

apps/client/package.json

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,11 +15,12 @@
1515
"class-variance-authority": "^0.7.1",
1616
"firebase": "^12.2.1",
1717
"framer-motion": "^12.23.12",
18+
"lottie-react": "^2.4.1",
1819
"react": "^19.1.1",
1920
"react-dom": "^19.1.1",
2021
"react-error-boundary": "^6.0.0",
21-
"react-router-dom": "^7.8.2",
22-
"lottie-react": "^2.4.1"
22+
"react-intersection-observer": "^10.0.3",
23+
"react-router-dom": "^7.8.2"
2324
},
2425
"devDependencies": {
2526
"@eslint/js": "^9.33.0",

apps/client/src/pages/jobPins/JobPins.tsx

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ const JobPins = () => {
1313
const { scrollContainerRef, isBottomNoticeVisible, handleBottomWheel } =
1414
useJobPinsBottomNotice();
1515

16-
const { data, isPending, fetchNextPage, hasNextPage } =
16+
const { data, isPending, fetchNextPage, hasNextPage, isFetchingNextPage } =
1717
useGetJobPinsArticles();
1818
const {
1919
mutate: getJobPinDetail,
@@ -22,9 +22,10 @@ const JobPins = () => {
2222
} = useGetJobPinsArticleDetail();
2323

2424
const observerRef = useInfiniteScroll({
25-
fetchNextPage,
26-
hasNextPage,
27-
root: scrollContainerRef,
25+
loadMore: fetchNextPage,
26+
hasMore: hasNextPage,
27+
isLoadingMore: isFetchingNextPage,
28+
rootRef: scrollContainerRef,
2829
});
2930

3031
const articlesToDisplay =

apps/client/src/pages/jobPins/apis/queries.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,13 +6,15 @@ import {
66
JobPinsDetailResponse,
77
} from './axios';
88

9+
const PAGE_SIZE = 20;
10+
911
export const useGetJobPinsArticles = () => {
1012
return useInfiniteQuery<JobPinsResponse>({
1113
queryKey: ['jobPinsArticles'],
1214
queryFn: ({ pageParam = 0 }) => getJobPinsArticles(pageParam as number, 20),
1315
initialPageParam: 0,
1416
getNextPageParam: (lastPage, allPages) => {
15-
if (lastPage.articles.length === 0) {
17+
if (lastPage.articles.length < PAGE_SIZE) {
1618
return undefined;
1719
}
1820

apps/client/src/pages/myBookmark/apis/queries.ts

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -3,15 +3,15 @@ import {
33
useSuspenseInfiniteQuery,
44
useSuspenseQuery,
55
} from '@tanstack/react-query';
6+
import { CategoryBookmarkArticleResponse } from '../types/api';
67
import {
78
getBookmarkArticles,
89
getBookmarkArticlesCount,
910
getCategoryBookmarkArticles,
1011
getCategoryBookmarkArticlesCount,
1112
} from './axios';
12-
import {
13-
CategoryBookmarkArticleResponse,
14-
} from '../types/api';
13+
14+
const PAGE_SIZE = 20;
1515

1616
export const useGetBookmarkArticles = (readStatus: boolean | null) => {
1717
return useSuspenseInfiniteQuery({
@@ -20,7 +20,7 @@ export const useGetBookmarkArticles = (readStatus: boolean | null) => {
2020
getBookmarkArticles(readStatus, Number(pageParam), 20),
2121
initialPageParam: 0,
2222
getNextPageParam: (lastPage, allPages) =>
23-
lastPage.articles.length === 0 ? undefined : allPages.length,
23+
lastPage.articles.length < PAGE_SIZE ? undefined : allPages.length,
2424
});
2525
};
2626

@@ -55,8 +55,8 @@ export const useGetCategoryBookmarkArticles = (
5555

5656
initialPageParam: 0,
5757
getNextPageParam: (lastPage, allPages) => {
58-
if (!lastPage || lastPage.articles.length === 0) return undefined;
59-
return allPages.length;
58+
if (!lastPage) return undefined;
59+
return lastPage.articles.length < PAGE_SIZE ? undefined : allPages.length;
6060
},
6161
});
6262
};

apps/client/src/pages/myBookmark/hooks/useMyBookmarkContentData.ts

Lines changed: 14 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -27,16 +27,18 @@ export const useMyBookmarkContentData = ({
2727
data: bookmarkArticlesData,
2828
fetchNextPage: fetchNextBookmarkArticles,
2929
hasNextPage: hasNextBookmarkArticles,
30+
isFetchingNextPage: isFetchingNextBookmarkArticles,
3031
} = useGetBookmarkArticles(readStatus);
32+
3133
const { data: bookmarkCountData } = useGetBookmarkArticlesCount();
32-
const { data: categoryCountData } = useGetCategoryBookmarkArticlesCount(
33-
categoryId
34-
);
34+
const { data: categoryCountData } =
35+
useGetCategoryBookmarkArticlesCount(categoryId);
3536

3637
const {
3738
data: categoryArticlesData,
3839
fetchNextPage: fetchNextCategoryArticles,
3940
hasNextPage: hasNextCategoryArticles,
41+
isFetchingNextPage: isFetchingNextCategoryArticles,
4042
} = useGetCategoryBookmarkArticles(categoryId, readStatus);
4143

4244
const categoryList =
@@ -62,14 +64,20 @@ export const useMyBookmarkContentData = ({
6264
const hasNextPage = isCategoryView
6365
? hasNextCategoryArticles
6466
: hasNextBookmarkArticles;
67+
6568
const fetchNextPage = isCategoryView
6669
? fetchNextCategoryArticles
6770
: fetchNextBookmarkArticles;
6871

72+
const isFetchingNextPage = isCategoryView
73+
? isFetchingNextCategoryArticles
74+
: isFetchingNextBookmarkArticles;
75+
6976
const sentinelRef = useInfiniteScroll({
70-
fetchNextPage,
71-
hasNextPage,
72-
root: scrollContainerRef,
77+
loadMore: fetchNextPage,
78+
hasMore: hasNextPage,
79+
isLoadingMore: isFetchingNextPage,
80+
rootRef: scrollContainerRef,
7381
});
7482

7583
return {

apps/client/src/pages/remind/Remind.tsx

Lines changed: 6 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -46,15 +46,14 @@ const Remind = () => {
4646
useGetArticleDetail();
4747
const { mutate: updateToReadStatus } = usePutArticleReadStatus();
4848
const { mutate: deleteArticle } = useDeleteRemindArticle();
49-
const { data, isPending, fetchNextPage, hasNextPage } = useGetRemindArticles(
50-
formattedDate,
51-
activeBadge === 'read'
52-
);
49+
const { data, isPending, fetchNextPage, hasNextPage, isFetchingNextPage } =
50+
useGetRemindArticles(formattedDate, activeBadge === 'read');
5351

5452
const observerRef = useInfiniteScroll({
55-
fetchNextPage,
56-
hasNextPage,
57-
root: scrollContainerRef,
53+
loadMore: fetchNextPage,
54+
hasMore: hasNextPage,
55+
isLoadingMore: isFetchingNextPage,
56+
rootRef: scrollContainerRef,
5857
});
5958

6059
const {
Lines changed: 43 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -1,51 +1,52 @@
11
import { useEffect, useRef } from 'react';
2+
import { useInView } from 'react-intersection-observer';
23

3-
interface UseInfiniteScrollProps {
4-
fetchNextPage: () => void;
5-
hasNextPage?: boolean;
6-
root?: React.RefObject<HTMLElement | null>;
7-
threshold?: number;
8-
}
4+
type UseInfiniteScrollProps = {
5+
/** 더 불러올 데이터가 있는지 */
6+
hasMore?: boolean;
7+
/** 추가 로딩 중인지 (중복 호출 방지용) */
8+
isLoadingMore?: boolean;
9+
/** 다음 데이터를 불러오는 함수 */
10+
loadMore: () => void | Promise<unknown>;
911

10-
/**
11-
* Intersection Observer를 사용하여 무한 스크롤을 구현하는 커스텀 훅
12-
* @returns observer가 감지할 엘리먼트에 연결할 ref 객체
13-
*/
12+
/** 내부 스크롤 컨테이너를 root로 쓰고 싶을 때 */
13+
rootRef?: React.RefObject<HTMLElement | null>;
14+
15+
/** 바닥에 닿기 전에 미리 불러오기 (ex: '200px 0px') */
16+
rootMargin?: string;
17+
18+
/** 무한 스크롤을 일시적으로 끄기 */
19+
enabled?: boolean;
20+
};
1421

1522
export const useInfiniteScroll = ({
16-
fetchNextPage,
17-
hasNextPage,
18-
root,
19-
threshold = 0.5,
23+
hasMore,
24+
isLoadingMore = false,
25+
loadMore,
26+
rootRef,
27+
rootMargin = '200px 0px',
28+
enabled = true,
2029
}: UseInfiniteScrollProps) => {
21-
const targetRef = useRef<HTMLDivElement>(null);
30+
const lockRef = useRef(false);
31+
32+
const { ref, inView } = useInView({
33+
root: rootRef?.current ?? null,
34+
threshold: 0,
35+
rootMargin,
36+
});
2237

2338
useEffect(() => {
24-
if (!hasNextPage) return;
25-
26-
const observer = new IntersectionObserver(
27-
([entry]) => {
28-
if (entry.isIntersecting) {
29-
fetchNextPage();
30-
}
31-
},
32-
{
33-
root: root?.current,
34-
threshold,
35-
}
36-
);
37-
38-
const currentTarget = targetRef.current;
39-
if (currentTarget) {
40-
observer.observe(currentTarget);
41-
}
42-
43-
return () => {
44-
if (currentTarget) {
45-
observer.unobserve(currentTarget);
46-
}
47-
};
48-
}, [fetchNextPage, hasNextPage, root, threshold]);
49-
50-
return targetRef;
39+
if (!enabled) return;
40+
if (!inView) return;
41+
if (!hasMore) return;
42+
if (isLoadingMore) return;
43+
if (lockRef.current) return;
44+
45+
lockRef.current = true;
46+
Promise.resolve(loadMore()).finally(() => {
47+
lockRef.current = false;
48+
});
49+
}, [enabled, inView, hasMore, isLoadingMore, loadMore]);
50+
51+
return ref;
5152
};

pnpm-lock.yaml

Lines changed: 18 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)