Notice
Recent Posts
Recent Comments
«   2024/07   »
1 2 3 4 5 6
7 8 9 10 11 12 13
14 15 16 17 18 19 20
21 22 23 24 25 26 27
28 29 30 31
Tags more
Archives
Today
Total
관리 메뉴

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

목표 달성여부

달성! 이제 최종제출이 얼마 남지 않았네!

내일 목표

  • 리팩토링