HYEWON JUNG의 개발일지
20240202 TIL supabase 무한 스크롤 구현하기 with react-intersection-observer, useInfiniteQuery 본문
20240202 TIL supabase 무한 스크롤 구현하기 with react-intersection-observer, useInfiniteQuery
혜won 2024. 2. 5. 08:29목표
- 목록페이지 페이지네이션=> 무한스크롤 변경
새로 알게 된것/ 오늘의 코드
당연히 시작은 패키지 설치!
yarn add react-query
yarn add react-intersection-observer
무한스크롤 적용하기
UX적으로 무한스크롤이 좋을 것 같다는 판단을 내려서 기존에 구현했던 페이지네이션을 지우고 무한스크롤로 변경하기로 했다.
기존에 마이페이지에선 무한스크롤을 구현하고 있었기 때문에 react-intersection-observer와 useInfiniteQuery를 사용하여 구현하셨기 때문에 통일성을 위해 나도 그렇게 구현을 했다.
서버상태 관리를 위해 react-query로 서버로직을 관리했다.
CommuQuery.ts
export const fetchPosts = async (
selectCategory: string,
page: number,
//페이징 수
limit: number = 12
//몇개 가져올지
) => {
const startIndex = (page - 1) * limit;
//가져올 데이터의 첫인덱스
const { data, error } = await supabase
.from('community')
.select('*')
.order('post_id', { ascending: false })
.range(startIndex, startIndex + limit - 1)
.ilike('category', selectCategory === '전체' ? '%' : selectCategory);
if (error) {
throw error;
}
return data;
};
페이지수는 간단하게 페이지네이션에서 12개씩 한 페이지로 다음페이지를 버튼으로 나타냈다면 무한스크롤은 데이터를 요청할때 한번 요청할때마다 가져오는 데이터를 한 페이지라고 생각하면 된다.
그래서 supabase 메소드인 range를 통해서 supabase 데이터상에 index를 기반으로 가져올 수 있다.
ilike는 필터기능인데 간단하게 해석하면
.ilike( //해당하는 것 가져오기
'category', //해당 테이블 categoy column에서
selectCategory === '전체' 인자로 받은 selectCategory가 '전체'라면
?
'%' 전체를 가져오고
:
selectCategory); //아니면 selectCategory와 완전히 같은 값을 가진 것만 가져와
이런 뜻이 된다. 만약에 selectCategory와 완전히 일치하지 않을 수도 있다면 `%${selectCategory}%` 이렇게 입력해줘야한다.! 하지만 나는 체크박스 였기 때문에 값이 일정해서 해당사항은 아니었다.
무한 스크롤 로직을 넣어주어야한다.
CommunityMain.tsx
const { ref, inView } = useInView();
useInview는 react-intersection-observer의 hook 이다.
여기서 ref는 요소를 뜻하고 inview는 요소가 뷰포트에 들어왔는지를 감지 한다.

간단하게 보면 이렇게 진행이 된다. 스크롤을 내려서 ref가 뷰포트에 들어가면 inview가 활성된다.
무한스크롤이니 데이터요청의 기준이 된다.
const { ref, inView } = useInView({
threshold: 0
});
이런식으로 설정을 할 수 있는데 threshold는 ref가 어느 정도 들어왔을 때 요청을 할 것인지 정하는 것이라고 생각하면 된다.
다음은 useInfiniteQuery 설정이다.
const {
data,
isError,
isLoading,
fetchNextPage,
hasNextPage,
isFetchingNextPage
} = useInfiniteQuery(
['posts', selectCategory],
({ pageParam = 1 }) => fetchPosts(selectCategory, pageParam, 6),
{
getNextPageParam: (lastPage, pages) => {
if (lastPage.length < 6) return undefined;
return pages.length + 1;
},
staleTime: 300000
}
);
useInfiniteQuery의 요소을 보면
data, isError, isLoading 은 설명을 생략하고
fetchNextPage 는 다음 페이지 데이터를 호출하는 함수
hasNextPage 는 다음페이지가 있는지 확인하는 불리언값
isFetchingNextPage 는 현재 다음페이지를 불러오고 있는 중인지 확인하는 불리언값이다.
그리고
({ pageParam = 1 }) => fetchPosts(selectCategory, pageParam, 6),
이부분은 page는 기본으로 1로 설정하고 query에 인자로 넘겨주는 것이다. 차례대로 , selectCategory, page, limit 다.
{
getNextPageParam: (lastPage, pages) => {
if (lastPage.length < 6) return undefined;
return pages.length + 1;
},
getNextPageParam은 다음페이지를 세팅하는 역할을 한다. 만약에 마지막으로 들어온 페이지의 데이터가 내가 설정한 limit보다 적다면 로딩을 중지한다.
뷰포트 감지 설정하기
useEffect(() => {
if (inView && hasNextPage) {
fetchNextPage();
}
}, [inView, hasNextPage]);
useEffect로 감싸고 의존성 배열에는 inview와 hasNextPage를 넣어준다.
만약에 ref가 inview안에 감지되었고 다음페이지가 있다면 데이터를 호출하는 함수인 fetchNextPage를 호출한다.
마지막으로
const posts: Post[] = data?.pages?.flat() || [];
데이터 형식이
[[page1데이터][page2데이터][page3데이터]] 이렇게 들어오기 때문에 배열을 합쳐줄 필요가 있다.
그래서 .flat()메소드를 사용했다.
tsx부분에는 어떻게 적용을 했냐면
우선 우리 프로젝트가 겹치는 컴포넌트가 있어서 postList를 컴포넌트로 분리를 했다.
전체 tsx인데
<St.Container>
<St.Post_container>
<CommunityMainCount selectCategory={selectCategory} />
<St.FeatureBar>
<CategorySelector
selectCategory={selectCategory}
setSelectCategory={setSelectCategory}
/>
<St.WriteBtn onClick={handleWriteButtonClick}>
<St.WriteIcon /> 글쓰기
</St.WriteBtn>
</St.FeatureBar>
{isLoading ? (
<SkeletonCommunityCard cards={6} />
) : (
<CommunityList posts={posts} />
)}
{hasNextPage && !isFetchingNextPage && <div ref={ref}></div>}
{isFetchingNextPage && <SkeletonCommunityCard cards={4} />}
</St.Post_container>
</St.Container>
조각조각으로 기록해두겠다.
{isLoading ? (
<SkeletonCommunityCard cards={6} />
) : (
<CommunityList posts={posts} />
)}
만약에 첫로딩중이라면? 스켈레톤 보여주기, 아니면 list 컴포넌트에 데이터 넘겨서 보여주기
{hasNextPage && !isFetchingNextPage && <div ref={ref}></div>}
다음페이지가 있고 다음페이지를 부르는 중이 아니라면 ref 활성화
{isFetchingNextPage && <SkeletonCommunityCard cards={4} />}
다음 데이터를 불러오고 있다면 스켈레톤보여주기
전체코드 확인하기
import React, { useEffect, useState } from 'react';
import { useInView } from 'react-intersection-observer';
import { useInfiniteQuery } from 'react-query';
import { useNavigate } from 'react-router-dom';
import CategorySelector from '../../components/community/CategorySeletor';
import CommunityList from '../../components/community/CommunityList';
import CommunityMainCount from '../../components/community/CommunityMainCount';
import SkeletonCommunityCard from '../../components/skeleton/SkeletonCommunityCard';
import * as St from '../../styles/community/CommunityMainStyle';
import { fetchPosts } from './api/commuQuery';
import { Post } from './api/model';
const CommunityMain: React.FC = () => {
const [selectCategory, setSelectCategory] = useState<string>('전체');
const [userId, setUserId] = useState('');
const navigate = useNavigate();
const { ref, inView } = useInView();
const {
data,
isError,
isLoading,
fetchNextPage,
hasNextPage,
isFetchingNextPage
} = useInfiniteQuery(
['posts', selectCategory],
({ pageParam = 1 }) => fetchPosts(selectCategory, pageParam, 6),
{
getNextPageParam: (lastPage, pages) => {
if (lastPage.length < 6) return undefined;
return pages.length + 1;
},
staleTime: 300000
}
);
useEffect(() => {
if (inView && hasNextPage) {
fetchNextPage();
}
}, [inView, hasNextPage]);
useEffect(() => {
const storedUserId = localStorage.getItem('userId');
if (storedUserId) {
setUserId(storedUserId);
}
}, []);
const handleWriteButtonClick = () => {
if (!userId) {
const confirmLogin = window.confirm(
'글쓰기는 로그인 후에 가능합니다. 로그인 하시겠습니까?'
);
if (confirmLogin) {
navigate('/login');
}
return;
}
navigate('/community_write');
};
const posts: Post[] = data?.pages?.flat() || [];
if (isError) {
alert(
'데이터 불러오기 중 오류가 발생했습니다. 새로고침후 현상이 유지된다면 개발자에게 문의주세요'
);
}
return (
<St.Container>
<St.Post_container>
<CommunityMainCount selectCategory={selectCategory} />
<St.FeatureBar>
<CategorySelector
selectCategory={selectCategory}
setSelectCategory={setSelectCategory}
/>
<St.WriteBtn onClick={handleWriteButtonClick}>
<St.WriteIcon /> 글쓰기
</St.WriteBtn>
</St.FeatureBar>
{isLoading ? (
<SkeletonCommunityCard cards={6} />
) : (
<CommunityList posts={posts} />
)}
{hasNextPage && !isFetchingNextPage && <div ref={ref}></div>}
{isFetchingNextPage && <SkeletonCommunityCard cards={4} />}
</St.Post_container>
</St.Container>
);
};
export default React.memo(CommunityMain);
개념정리
원래는 react-intersection-observer만 이용해서 코드는 구현했었는데 데이터를 다 가져왔음에도 계속 로딩이 일어나는 이슈가 있었다 . 또한 코드 길이도 엄청 길어지기도 했고 가독성도 떨어졌다. 무한스크롤은 학습목적이 아니면 reactInfiniteQuery를 사용하는 것이 정말정말 현명한 선택이다!
reactInfiniteQuery로 구현 하지 않고 react- ntersection-observer만 사용했을 때 코드
const [selectCategory, setSelectCategory] = useState<string>('전체');
const [userId, setUserId] = useState('');
const [posts, setPosts] = useState<Post[]>([]);
const [page, setPage] = useState(1);
const [hasMore, setHasMore] = useState(true);
const [isLoading, setIsLoading] = useState(false);
const navigate = useNavigate();
const { data: count, isError } = useQuery(
['posts', selectCategory],
() => getPostCount(selectCategory),
{
staleTime: 60000,
onSuccess: (count) => {
// 여기서 selectCategory를 이용한 작업을 수행
console.log('Data fetched successfully', count);
}
}
);
// `useInView` 훅으로 관찰할 요소와 옵션을 설정
const { ref, inView } = useInView({
threshold: 0
});
useEffect(() => {
const fetchInitialPosts = async () => {
setIsLoading(true);
try {
const newPosts = await fetchPosts(selectCategory, 1, 6);
setPosts(newPosts);
setPage(2);
setHasMore(newPosts.length === 6);
} catch (error) {
console.error('Error loading initial posts:', error);
} finally {
setIsLoading(false);
}
};
fetchInitialPosts();
}, [selectCategory]);
useEffect(() => {
if (inView && hasMore && !isLoading) {
fetchMorePosts();
}
}, [inView, selectCategory]);
const fetchMorePosts = async () => {
setIsLoading(true);
try {
const newPosts = await fetchPosts(selectCategory, page, 6);
if (newPosts.length === 0) {
setHasMore(false);
setIsLoading(false);
} else {
setPosts((prev) => [
...prev,
...newPosts.filter(
(post) => !prev.some((p) => p.post_id === post.post_id)
)
]);
setPage((prevPage) => prevPage + 1);
setIsLoading(false);
}
} catch (error) {
console.error('Error loading more posts:', error);
setIsLoading(false);
}
};
reactInfiniteQuery
목표 달성여부
달성! 이제 최종제출이 얼마 남지 않았네!
내일 목표
- 리팩토링
'개발일지' 카테고리의 다른 글
20240131 TIL 댓글 로직 수정 / 댓글 테이블 분리/ 대댓글 (1) (0) | 2024.02.01 |
---|---|
202401226 TIL react-quill 커서 이동 에러 (0) | 2024.01.29 |
20240125 TIL 간이 유효성 검사 추가하기 (0) | 2024.01.26 |
20240124 TIL 페이지 네이션하기 / 반응형 (0) | 2024.01.25 |
20240123 TIL 추천기능 추가하기 (0) | 2024.01.25 |