ABOUT ME

-

Today
-
Yesterday
-
Total
-
  • [ReactJS] React Query + IntersectionObserver = Infinite Scroll Paging(리액트 쿼리 + 옵저버 = 무한 스크롤 페이징)
    frontend/react&next 2022. 2. 24. 14:53

    최근 Client State와 Server State관리에 대하여 많은 Redux에 Redux Saga등 덕지덕지 붙으며 본연의 클라이언트 상태관리의 의미가 퇴색되고 서버측 상태(API데이터 등)이 주가 되고 있어서 이런 구조적 문제를 타파하고자 Client Side(Mobx , Recoil) 등과 React Query를 이용해서 작업하는 경우가 참 많은거 같습니다. 저 또한 하나의 기능을 만들기 위하여 과하게 작업되는 경우가 많은거 같아서 Redux + Redux Saga로 작업되는 개인 프로젝트에서 이 구조를 버리고 Recoil + React Query(리코일 + 리액트쿼리)로 작업하였는데 작업 중에 Infinite Scroll(무한스크롤)을 구현한 케이스가 있어서 이를 공유하고자 글을 써봅니다. 잡소리가 길었으니 바로 코드부터 보겠습니다. 이론은 React Query 공식문서를 참고하시면 감사하겠습니다.

     

    글이 난잡하면 글 맨마지막에 관련 FULL 소스 코드가 있으니 참고해주세요.

     

    예제

     

    가장 먼저 Infinite Scroll Paging(무한 스크롤 페이징)을 구현하기 위해서 무엇이 필요할지 생각해보면 당연하게도 이 문서의 마지막이 어디인가?를 판별할 수 있는 무엇인가가 필요합니다. 그러기 위해서 우리는 IntersectionObserver API 를 이용해야 됩니다. (링크걸어뒀으니 참고하세요)

     

    useIntersectionObeserver.js

    import React from 'react'
    /**
    root : default null : 뷰포트
    target : 감지 대상
    onIntersect : 수행할 인터섹션
    threshold : 교차 감지 비율
    enabled : 동작 여부
    */
    export default function useIntersectionObserver({
      root,
      target,
      onIntersect,
      threshold = 1.0,
      rootMargin = '0px',
      enabled = true
    }) {
      // useEffect 훅을 이용
      React.useEffect(()=>{
        if(!enabled){
          return;
        }
        /* root : 대상 객체의 가시성을 확인할 때 사용되는 뷰포트 요소, 
                  default 는 null 이며, 브라우저의 viewport
           rootMargin : root 가 가진 여백
           threshold : 교차 영역 비율 0일때는 교차영역 진입감지, 1일때는 다보일 떄
        */
        const observer = new IntersectionObserver(
          entries =>
            entries.forEach(entry => entry.isIntersecting && onIntersect()),{
              root: root&&root.current,
              rootMargin,
              threshold
            }
        )
        
        if(!el){
          return;
        }
        return () => {
          observer.unonbserve(el)
        }
      },[target.current,enabled)
    }

    IntersectionObserver로 구성되어 있는 커스텀 훅은 설명드리자면, 옵저버를 생성하고 감지할 엘리먼트가 있을 시에 엘리먼트를 감지하는 훅입니다. 즉, 우리가 리스트를 구성하였을 때 맨 마지막 리스트의 끝 부분 하단에 빈 엘리먼트를 만들어 놓고 해당 객체가 감지되면 onIntersect를 수행하여 주는 Observer Hook입니다.

     

    이것을 실제로 어떻게 사용해야 될 지 계속해서 알아 보겠습니다.

     

    화면 쪽 스크립트

    const {
        status,
        data,
        fetchNextPage,
        hasNextPage,
        isFetchingNextPage
    } = useInfiniteQuery(
        ['issuelist', useDebounce(search, 1000), toggle],
        async ({ pageParam = { query: search, page: 0 } }) => {
            return await getIssues({ query: pageParam.query, page: pageParam.page })
        },
        {
            getNextPageParam: lastPage => {
                return lastPage.nextId ? { query: search, page: lastPage.nextId } : false
            },
            refetchOnMount: false,
            refetchOnWindowFocus: false,
        }
    )
    
    const hasMoreChecker = useRef(null);
    
    useIntersectionObserver({
        target: hasMoreChecker,
        onIntersect: fetchNextPage,
        enabled: !!hasNextPage,
    })

     스크립트 코드는 위와 같이 생겼습니다. react-query의 무한 스크롤을 위한 InifiniteQuery가 있습니다. 첫번째 파라미터로는 queryKey입니다. 해당값이 변하면 재조회를 돌리게 됩니다. 값의 변동이 없을때는 API통신 없이 캐시에서 데이터를 불러옵니다. 저는 배열로 사용하였으며 조회를 위한 search값과 재조회를 위한 toggle을 두었습니다. 그리고 두번째 파라미터는 API입니다. 조회할 API를 넣어주시면 되고 그 후 세번째 파라미터인 옵션에 getNextPageParam에서 다음 페이지조회를 위한 Api 파라미터 셋팅을 하기도하고 refetch조건 등을 걸어주시면 됩니다.

     

    이렇게 간단하게 구성한 infiniteQuery의 반환 값을 보겠습니다. 실질적으로 간단한 무한스크롤을 구현하기 위해서는 반환 값으로 모두 필요한 것은 아니고 status, data, fetchNextPage, hasNextPage, isFetchingNextPage 등 만 반환값으로 가지고 있으면 됩니다.

    1. status : api Status ( "loading", "success", "error")
    2. data : response 데이터
    3. fetchNextPage : 다음 페이지조회를 위한 action
    4. hasNextPage : 다음 페이지 조회 가능 여부
    5. isFetchingNextPage : 페이징 loading중 일때 감지 (Status)

    그 리고 hasMoreChecker는 useRef를 이용하여 나중에 감지대상에 할당하여 주면 됩니다. 그후 아까 만들어 두었던 useIntersectionObserver를 보겠습니다.

    1. target : hasMoreChecker를 할당 받아서 감지 대상을 지정해주고
    2. onIntersect : 감지시 fetchNextPage를 수행
    3. enabled : 다음페이지가 있는지를 판별

    어떻게 동작하게 될지는 알겠죠? 그럼 화면코드에서 한번 보겠습니다.

    {status === "loading" ?
     <CardSkeletonGroup /> :
     status === "error" ?
     <></> :
     data?.pages && data?.pages[0].data.length !== 0 ?
        data?.pages.map((value: any, index: any) => {
            return <React.Fragment key={index}>
                {value.data.map((value: any, index: any) => {
                    return <Card title={value?.title} content={value.content.replace(/(<([^>]+)>)/gi, "")} name={value.developer.name} date={value.modifiedDate.substring(0,10).replace(/-/gi,".")} count={value.solutionCount} adoptYn={value.adoptYn} key={index} index={value.id} />
                })} </React.Fragment>}) : 
        <h1 style={{ textAlign: 'center' }}>검색된 이슈가 없습니다.</h1>
    }
    <div ref={hasMoreChecker}></div>
    {
        isFetchingNextPage ? <> <CardSkeletonGroup /></> : <></>
    }

    상태값을 loading과 error 그리고 success로 나누고 있으며, 해당 로딩일땐 스켈레톤 카드를 부여주고 success되면 실제 카드리스트를 보여주며며 화면에 hasMoreChecker가 감지되면 isFetchingNextPage 가 true(로딩)중일때 세켈레톤 카드를 보여주는 코드 입니다.

     

    Full SourceCode

     

    useIntersectionObeserver.js

    import React from 'react'
    
    export default function useIntersectionObserver({
        root,
        target,
        onIntersect,
        threshold = 1.0,
        rootMargin = '0px',
        enabled = true,
    }:any) {
        React.useEffect(() => {
            if (!enabled) {
                return
            }
            const observer = new IntersectionObserver(
                entries =>
                    entries.forEach(entry => entry.isIntersecting && onIntersect()),
                {
                    root: root && root.current,
                    rootMargin,
                    threshold,
                }
            )
            console.log(observer)
    
            const el = target && target.current
    
            if (!el) {
                return
            }
    
            observer.observe(el)
    
            return () => {
                observer.unobserve(el)
            }
        }, [target.current, enabled])
    }

    ListContainer.tsx

    import { getIssues } from "api/modules/issue";
    import { useDebounce } from "hooks/useDebounce";
    import useIntersectionObserver from "hooks/useIntersectionObserver";
    import { useEffect, useRef } from "react";
    import { useInfiniteQuery } from "react-query";
    import { useRecoilState } from "recoil";
    import List from "src/components/issue/List";
    import { searchAtom, toggleAtom } from "store/atom";
    
    const ListContainer = () => {
        const [search, setSearch] = useRecoilState(searchAtom);
        const [toggle, setToggle] = useRecoilState(toggleAtom);
    
        useEffect(() => {
            if (toggle) {
                setToggle(false);
            }
        })
    
    
        const {
            status,
            data,
            fetchNextPage,
            hasNextPage,
            isFetchingNextPage
        } = useInfiniteQuery(
            ['issuelist', useDebounce(search, 1000), toggle],
            async ({ pageParam = { query: search, page: 0 } }) => {
                return await getIssues({ query: pageParam.query, page: pageParam.page })
            },
            {
                getNextPageParam: lastPage => {
                    return lastPage.nextId ? { query: search, page: lastPage.nextId } : false
                },
                refetchOnMount: false,
                refetchOnWindowFocus: false,
            }
        )
    
        const hasMoreChecker = useRef(null);
    
        useIntersectionObserver({
            target: hasMoreChecker,
            onIntersect: fetchNextPage,
            enabled: !!hasNextPage,
        })
        return <List search={search} isFetchingNextPage={isFetchingNextPage} hasMoreChecker={hasMoreChecker} setSearch={(e: any) => { e.preventDefault(); setSearch(e.target.value) }} status={status} data={data} />
    }
    export default ListContainer;

    List.tsx

    import React from "react";
    import styled from "styled-components";
    import Search from "../common/Search";
    import Card from "./Card";
    import CardSkeleton from "./CardSkeleton";
    
    const ListStyle = styled.div`
        height:100%;
        display:grid;
        grid-template-columns:  1fr minmax(auto,35rem) 1fr;
        grid-template-rows:1fr;
        grid-template-areas: "left middle right";
        @media only screen and (max-width: 550px) {
            grid-template-columns: minmax(1rem,auto) minmax(20rem,auto) minmax(1rem,auto);
            grid-template-rows: 1fr;
            grid-template-areas: "left middle right";
        }
    `;
    const SearchPanel = styled.div`
        grid-area:"middle";
    `;
    const LeftPanel = styled.div`
        grid-area:"left";
    `;
    const RightPanel = styled.div`
        grid-area:"right";
    `;
    const CardSkeletonGroup = () => {
        return <>
            <CardSkeleton />
            <CardSkeleton />
            <CardSkeleton />
        </>
    }
    
    const List = ({ search, status, data, setSearch, hasMoreChecker, isFetchingNextPage }: { search: string, status: string, data: any, setSearch: any, hasMoreChecker: any, isFetchingNextPage: any }) => {
        return <ListStyle>
            <LeftPanel />
            <SearchPanel>
                <Search style={{ 'margin': '2rem 0' }} placeholder="검색어를 입력해주세요" search={search} setSearch={setSearch} />
    
                {status === "loading" ?
                    <CardSkeletonGroup /> :
                    status === "error" ?
                        <></> :
                        data?.pages && data?.pages[0].data.length !== 0 ?
                            data?.pages.map((value: any, index: any) => {
                                return <React.Fragment key={index}>
                                    {value.data.map((value: any, index: any) => {
                                        return <Card title={value?.title} content={value.content.replace(/(<([^>]+)>)/gi, "")} name={value.developer.name} date={value.modifiedDate.substring(0,10).replace(/-/gi,".")} count={value.solutionCount} adoptYn={value.adoptYn} key={index} index={value.id} />
                                    })}
                                </React.Fragment>
                            }) : <h1 style={{ textAlign: 'center' }}>검색된 이슈가 없습니다.</h1>
                }
                <div ref={hasMoreChecker}></div>
                {
                    isFetchingNextPage ? <> <CardSkeletonGroup /></> : <></>
                }
            </SearchPanel>
    
            <RightPanel />
        </ListStyle>
    }
    export default List;

    이상 제가 구성한 InfiniteScroll 입니다. 많은 도움이 되셨으면 좋겠습니다. 제 코드도 엉성한 부분이 많으니 피드백 주시면 감사하겠습니다.

    반응형

    댓글

Designed by Tistory.