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() 속성을 이용하여 이미지가 모두 불러와졌을 경우 isLoadingSkeletonfalse로 설정한다.
    • 만약 이미지가 모두 불러와지지 않았을 경우(isLoadingSkeleton : false), 스켈레톤 효과를 적용시킨 요소를 표시한다.

 

Gallery.jsx
  • useInfiniteQuery()를 사용하여 무한 스크롤 기능을 구현하였다.
    • getNextPageParm은 다음 페이지를 결정하는 함수이다.
    • onenabled에는 검색어가 있을 때만 쿼리가 실행되도록 조건을 추가하였다.
      • 느낌표 2개(!!)를 사용하면, 첫 번째 !로 값을 falsy하게 변환한 후, 두 번째 !로 다시 boolean으로 변환할 수 있다.
      • 예) searchTerm 빈 문자열("")일 경우,
        • !searchTermtrue (빈 문자열은 falsy)
        • !!searchTermfalse (최종 결과는 boolean false
  • 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 함수에서 요소가 화면에 들어오면 isVisibletrue로 설정하고, 더 이상 관찰하지 않도록 한다.
  • 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)하는지 감지하는데 사용한다.
    • 사용자가 스크롤할 때 이미지가 뷰포트에 들어오면 이미지를 로드하고, 그렇지 않으면 로딩 스켈레톤을 표시한다.

 

참고 사이트

 

TanStack | High Quality Open-Source Software for Web Developers

Headless, type-safe, powerful utilities for complex workflows like Data Management, Data Visualization, Charts, Tables, and UI Components.

tanstack.com

 

IntersectionObserver - Web API | MDN

Intersection Observer API의 IntersectionObserver 인터페이스는 대상 요소와 상위 요소, 또는 대상 요소와 최상위 문서의 뷰포트가 서로 교차하는 영역이 달라지는 경우 이를 비동기적으로 감지할 수 있는

developer.mozilla.org

 

728x90
728x90

무한 스크롤(Infinite Scroll), 스켈레톤(Skeleton) 효과 적용하기 (with React Query)들어가며코드개념 정리useInfiniteQuery()개념기능사용 예IntersectionObserver개념기능사용 예정리참고 사이트