728x90
728x90
무한 스크롤(Infinite Scroll), 스켈레톤(Skeleton) 효과 적용하기 (with React Query)
들어가며
- React Query를 이용하여 무한 스크롤(Infinite Scroll)과 스켈레톤(Skeleton) 효과를 적용한 예제 코드를 올려본다.
- 개인 미니 프로젝트를 진행하면서 만들었던 코드이다.
- 검색어를 입력하면, 해당 검색어와 관련된 이미지를 표시하는 사이트
- Unsplash 공개 API를 이용하여 구현하였다.
코드
- 리액트 쿼리의
useInfiniteQuery()
를 이용하여 무한 스크롤(Infinite Scroll) 기능을 구현하였다.IntersectionObserver
를 사용하여 스크롤이 페이지의 맨 아래에 도달했을 때만 다음 페이지의 데이터를 불러오도록 설정하였다.
useState
를 이용하여 스켈레톤(Skeleton) 기능을 구현하였다.const [isLoadingSkeleton, setIsLoadingSkeleton] = useState(true);
-
useInfiniteQuery()
의onSuccess()
속성을 이용하여 이미지가 모두 불러와졌을 경우isLoadingSkeleton
를false
로 설정한다. - 만약 이미지가 모두 불러와지지 않았을 경우(
isLoadingSkeleton
:false
), 스켈레톤 효과를 적용시킨 요소를 표시한다.
Gallery.jsx
useInfiniteQuery()
를 사용하여 무한 스크롤 기능을 구현하였다.getNextPageParm
은 다음 페이지를 결정하는 함수이다.onenabled
에는 검색어가 있을 때만 쿼리가 실행되도록 조건을 추가하였다.- 느낌표 2개(
!!
)를 사용하면, 첫 번째!
로 값을falsy
하게 변환한 후, 두 번째!
로 다시boolean
으로 변환할 수 있다. - 예)
searchTerm
이 빈 문자열(""
)일 경우,!searchTerm
→true
(빈 문자열은falsy
)!!searchTerm
→false
(최종 결과는 booleanfalse
)
- 느낌표 2개(
IntersectionObserver
를 사용하여 특정 요소가 뷰포트에 들어오면 다음 페이지를 로드하도록 설정하였다.
import axios from 'axios'; import { useInfiniteQuery } from '@tanstack/react-query'; import { useRef, useCallback, useState } from 'react'; import { useGlobalContext } from '../contexts/context'; const url = `https://api.unsplash.com/search/photos?client_id=${ import.meta.env.VITE_API_KEY }`; const Gallery = () => { const { searchTerm } = useGlobalContext(); const loader = useRef(null); // 한 번에 불러올 이미지 개수 설정 const itemsCountParam = 9; const [isLoadingSkeleton, setIsLoadingSkeleton] = useState(true); const { data, fetchNextPage, hasNextPage, isFetchingNextPage, isError, isLoading, } = useInfiniteQuery({ queryKey: ['images', searchTerm], queryFn: async ({ pageParam = 1 }) => { const response = await axios.get( `${url}&query=${searchTerm}&page=${pageParam}&per_page=${itemsCountParam}` ); return response.data; }, getNextPageParam: (lastPage, allPages) => { // 다음 페이지가 있는지 확인 if (lastPage.results.length < 1) return undefined; return allPages.length + 1; }, enabled: !!searchTerm, // searchTerm이 있을 때만 쿼리 실행 onSuccess: () => { setIsLoadingSkeleton(false); // 성공 시 skeleton 제거 }, }); // 특정 시간 뒤에 불러오기 const loadNextPageWithDelay = useCallback(() => { if (hasNextPage) { setTimeout(() => { fetchNextPage(); }, 3000); // 3초 } }, [fetchNextPage, hasNextPage]); const observer = useCallback( (node) => { if (isLoading || isFetchingNextPage) return; if (loader.current) loader.current.disconnect(); loader.current = new IntersectionObserver((entries) => { if (entries[0].isIntersecting && hasNextPage) { loadNextPageWithDelay(); } }); if (node) loader.current.observe(node); }, [isLoading, isFetchingNextPage, fetchNextPage, hasNextPage] ); // 로딩 중일 때 if (isLoading) { return ( <section className="image-container-wrapper"> <h4 className="description">Loading...</h4> </section> ); } // 에러 발생 시 if (isError) { return ( <section className="image-container-wrapper"> <h4 className="description">There was an error...</h4> </section> ); } // 결과가 없을 때 if (data?.pages[0].results.length < 1) { return ( <section className="image-container-wrapper"> <h4 className="description">No results found...</h4> </section> ); } return ( <section className="image-container-wrapper"> <div className="image-container"> {data?.pages.map((page, pageIndex) => page.results.map((item, index) => { const url = item?.urls?.regular; const isSkeleton = isLoadingSkeleton && pageIndex === data.pages.length - 1; return isSkeleton & (pageIndex > 0) ? ( <div key={index} className="img skeleton" /> ) : ( <div key={item.id} className="img-wrapper"> <img src={url} alt={item.alt_description} className="img" /> <span className="tooltip">{item.alt_description}</span> </div> ); }) )} </div> <div ref={observer} style={{ height: '20px', marginBottom: '20px' }}> {isFetchingNextPage ? ( <h4 className="description">Loading more...</h4> ) : ( <h4 className="description">Loading...</h4> )} </div> </section> ); }; export default Gallery;
index.css
/* ... */ .image-container-wrapper { display: flex; flex-direction: column; justify-content: center; align-items: center; margin: 5rem auto; width: var(--view-width); max-width: var(--max-width); background: rgba(255, 255, 255, 0.1); backdrop-filter: blur(10px); -webkit-backdrop-filter: blur(10px); border-radius: 15px; border: 1px solid rgba(255, 255, 255, 0.2); box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1); } .image-container { margin: 3rem auto; display: grid; gap: 2rem; } .image-container .img { height: 10rem; border-radius: 0.8rem; cursor: pointer; transition: all 0.3s; box-sizing: border-box; border: 1.5px solid rgba(0, 0, 0, 0.2); } /* ... */ .skeleton { background-color: #e0e0e0; background-image: linear-gradient(90deg, #e0e0e0, #f0f0f0, #e0e0e0); background-size: 200% 100%; animation: shimmer 1.5s infinite; cursor: default !important; } .skeleton:hover { transform: none !important; border-color: transparent !important; } @keyframes shimmer { 0% { background-position: -200% 0; } 100% { background-position: 200% 0; } }
개념 정리
useInfiniteQuery()
개념
- 리액트 애플리케이션에서 무한 스크롤링이나 페이지네이션을 이용할 때 유용한 훅
- 이 훅을 사용하면 서버에서 데이터를 페이지 단위로 요청하고, 각 페이지마다 받은 데이터를 캐싱하여 관리할 수 있다.
기능
useInfiniteQuery()
는 백그라운드에서 데이터를 요청하고, 받은 데이터를 자동으로 캐싱하여 성능을 최적화한다.- 각 페이지의 데이터는 쿼리 키(key)에 의해 관리된다.
- 서버로부터 페이지별 데이터를 요청하며, 새로운 페이지를 로드할 때 이전 페이지의 데이터와 합쳐져 화면에 표시한다.
- 무한 스크롤링이나 '더 보기' 버튼과 같은 UI 패턴을 구현하는 데 유용하다.
- 리액트 쿼리는 캐시된 데이터를 자동으로 유지하며, 새로운 데이터 요청 시 이전에 받은 데이터를 재사용할 수 있다.
- 중복된 요청을 줄이고 애플리케이션의 반응성을 향상시킨다.
- 필요한 경우 자동으로 데이터를 리패치하여 최신 데이터를 유지한다.
- 예) 데이터가 변경되거나 사용자의 요청에 따라 데이터를 업데이트할 수 있다.
사용 예
useInfiniteQuery()
는/api/posts
엔드포인트에서 페이지별로 포스트 데이터를 요청하며, 페이지마다 받은 데이터를 화면에 표시한다.fetchNextPage()
함수를 사용하여 추가 페이지를 로드하고,isFetching
을 사용하여 데이터를 가져오는 동안 로딩 상태를 표시한다.
import { useInfiniteQuery } from 'react-query'; function MyComponent() { const fetchPosts = async (key, nextPage = 0) => { const response = await fetch(`/api/posts?page=${nextPage}`); return response.json(); }; const { data, fetchNextPage, hasNextPage, isFetching } = useInfiniteQuery( 'posts', fetchPosts, { getNextPageParam: (lastPage) => lastPage.nextPage, // 페이지마다 다음 페이지를 결정하는 함수 } ); return ( <div> {data.pages.map((page, pageIndex) => ( <React.Fragment key={pageIndex}> {page.posts.map((post) => ( <div key={post.id}>{post.title}</div> ))} </React.Fragment> ))} {hasNextPage && ( <button onClick={() => fetchNextPage()}>더 보기</button> )} {isFetching && <p>로딩 중...</p>} </div> ); }
IntersectionObserver
개념
- 웹 API 중 하나로, DOM 요소가 다른 요소(또는 뷰포트)에 교차(Intersect)하는지 감지하는 데 사용된다.
- 주로 무한 스크롤, lazy loading(지연 로딩), 애니메이션 트리거 등에서 유용하게 활용된다.
기능
- 비동기적으로 작동하며, 특정 요소의 가시성(화면에 보이는지 여부)을 감지한다.
- 스크롤 이벤트 리스너를 사용하는 대신,
IntersectionObserver
를 사용하면 성능을 개선할 수 있다.- 스크롤 시 매번 이벤트를 호출하는 것이 아니라, 요소가 화면에 들어오거나 나갈 때만 호출되기 때문이다.
- 관찰할 요소의 비율, 마진 등 다양한 옵션을 설정할 수 있다.
- 예) 특정 비율 이상으로 요소가 화면에 나타났을 때만 콜백 함수를 실행하도록 설정할 수 있다.
사용 예
IntersectionObserver 생성
const observer = new IntersectionObserver((entries, observer) => { entries.forEach(entry => { if (entry.isIntersecting) { // 요소가 화면에 보일 때 console.log('Element is visible!'); } else { // 요소가 화면에서 사라질 때 console.log('Element is not visible.'); } }); }, { root: null, // viewport 기준 rootMargin: '0px', threshold: 1.0 // 100% 보일 때만 콜백 실행 });
관찰할 요소 지정
const target = document.querySelector('.target-element'); observer.observe(target); // 특정 요소 관찰 시작
관찰 중단
observer.unobserve(target); // 특정 요소의 관찰 중단
관찰자 해제
observer.disconnect(); // 모든 관찰 중단
리액트에서의 사용 예제
LazyImage
컴포넌트isVisible
상태를 관리하여 이미지가 보일 때만 로드되도록 한다.imageRef
를 사용하여 해당 DOM 요소를 참조한다.observerCallback
함수에서 요소가 화면에 들어오면isVisible
을true
로 설정하고, 더 이상 관찰하지 않도록 한다.
useEffect
훅을 이용하여IntersectionObserver
를 생성하고, 관찰할 요소를 지정한다.
- 컴포넌트가 언마운트될 때
unobserve
를 호출하여 메모리 누수를 방지한다.
- 컴포넌트가 언마운트될 때
import { useEffect, useRef, useCallback, useState } from 'react'; const LazyImage = ({ src, alt }) => { const [isVisible, setIsVisible] = useState(false); const imageRef = useRef(null); const observerCallback = useCallback((entries) => { entries.forEach(entry => { if (entry.isIntersecting) { setIsVisible(true); observer.unobserve(imageRef.current); // 관찰 중단 } }); }, []); useEffect(() => { const observer = new IntersectionObserver(observerCallback, { root: null, // viewport 기준 rootMargin: '0px', threshold: 0.1, // 10% 보일 때 }); if (imageRef.current) { observer.observe(imageRef.current); // 요소 관찰 시작 } return () => { if (imageRef.current) { observer.unobserve(imageRef.current); // 컴포넌트 언마운트 시 관찰 중단 } }; }, [observerCallback]); return ( <div ref={imageRef} style={{ minHeight: '200px', marginBottom: '20px' }}> {isVisible ? <img src={src} alt={alt} style={{ width: '100%' }} /> : <div className="skeleton" style={{ height: '100%' }} />} </div> ); }; const Gallery = () => { const images = [ { src: 'https://via.placeholder.com/600x400/FF5733/FFFFFF?text=Image+1', alt: 'Image 1' }, { src: 'https://via.placeholder.com/600x400/33FF57/FFFFFF?text=Image+2', alt: 'Image 2' }, { src: 'https://via.placeholder.com/600x400/3357FF/FFFFFF?text=Image+3', alt: 'Image 3' }, ]; return ( <div> {images.map((image, index) => ( <LazyImage key={index} src={image.src} alt={image.alt} /> ))} </div> ); }; export default Gallery;
정리
- 리액트 라우터(React Router)의
useInfiniteQuery
를 이용하여 무한 스크롤(Infinite Scroll) 기능을 구현할 수 있다. IntersectionObserver
를 이용하여 스켈레톤(Skeleton) 효과를 구현할 수 있다.IntersectionObserver
는 웹 API이며, DOM 요소가 다른 요소(또는 뷰포트)에 교차(Intersect)하는지 감지하는데 사용한다.- 사용자가 스크롤할 때 이미지가 뷰포트에 들어오면 이미지를 로드하고, 그렇지 않으면 로딩 스켈레톤을 표시한다.
참고 사이트
728x90
728x90
'Programming > React' 카테고리의 다른 글
[React.js] Thunk API (Redux Toolkit) (1) | 2024.09.28 |
---|---|
[React.js] 리액트 라우터(React Router) (0) | 2024.09.26 |
[React.js] 라우팅 관련 기능들 정리 (React Router) : useNavigate, useNavigation, redirect, useLocation, useParams, useHistory, Navigate (1) | 2024.09.26 |
[React.js] 폼 데이터 처리하기 (React, React Router) (1) | 2024.09.26 |
[React.js] .env 파일 만들고 사용하기 (환경 변수 관리) (0) | 2024.09.23 |
[React.js] React Query Devtools (0) | 2024.09.22 |
[React.js] 코드 분할(Code Splitting) : useTransition 훅, Suspense 컴포넌트, lazy 함수 (0) | 2024.09.20 |
[React.js] useMemo 훅 (0) | 2024.09.20 |