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@ (최종 결과는 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@ 함수에서 요소가 화면에 들어오면 @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)하는지 감지하는데 사용한다.
- 사용자가 스크롤할 때 이미지가 뷰포트에 들어오면 이미지를 로드하고, 그렇지 않으면 로딩 스켈레톤을 표시한다.
참고 사이트
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
'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 |