@@@ 결과 미리보기@@@
1. 구현
데이터가 많을 때 이를 한 번에 모두 렌더링 하면 성능 저하로 이어질 수 있다. 특히 스크롤을 통해 추가적인 데이터를 보여주는 무한 스크롤 방식은, 성능 최적화를 위해 사용자와의 상호작용에 따라 데이터가 로딩되도록 하는 것이 중요하다.
이를 위해 Intersection Observer API와 더보기 버튼을 활용해 특정 위치까지 스크롤이 되면 추가 데이터를 로딩하고, 버튼 클릭 시 모든 데이터를 표시하는 방식으로 컴포넌트를 구현할 수 있다.
이번 글에서는 Intersection Observer와 상태 관리를 통해 효율적으로 데이터를 로딩하고 화면에 표시하는 방법을 설명하도록 하겠다.
1. Intersection Observer 설정
const observer = useRef(
new IntersectionObserver(
(entries) => {
const first = entries[0];
if (first.isIntersecting) {
loadMoreItems();
}
},
{ threshold: 1 }
)
);
Intersection Observer를 사용하여 특정 요소가 뷰포트에 보이는지 감지할 수 있다. 아이템 위치까지 스크롤을 내렸을 때 해당아이템의 보인다면 그때 loadMoreItems를 호출하여 이미지나 데이터를 렌더링 하는 방식이다.
이때 threshold값을 0부터 1까지 조절할 수 있는데, 0.2면 20%가 보이면 렌더링 1이면 100%가 다 보여야 렌더링 한다.
2. 아이템 관찰 target
const [target, setTarget] = useState(null);
useEffect(() => {
const currentElement = target;
const currentObserver = observer.current;
return () => {
if (currentElement) {
currentObserver.unobserve(currentElement);
}
};
}, [target]);
target은 관찰할 요소를 저장하는 상태다. Intersection Observer는 이 target 요소가 뷰포트에 들어오면 데이터 로딩을 트리거하는 역할을 한다. useEffect 훅은 target이 변경될 때마다 기존 관찰을 해제하고, 새로운 target 요소를 관찰하게 한다.
3. 아이템 로딩 관리 초기값
const [visibleItems, setVisibleItems] = useState(10);
const loadMoreItems = () => {
setVisibleItems((prevVisibleItems) => prevVisibleItems + 8);
};
기본적으로 초기에 10개의 아이템을 보여준다.
그리고 스크롤을 내렸을 때 추가로 8개씩 추가로 로드가 될 수 있도록 visibleItems State에 이전값 +8을 해준다.
4. 초기 데이터와 전체 데이터 구분 viewAll
CompanyList 컴포넌트는 enterpriseData 배열을 받아 각 데이터를 EnterprisesItem 컴포넌트로 표시한다. 이때 데이터의 양이 많으면 한 번에 모두 렌더링 하지 않고, 미리보기 형식으로 상위 12개 이하 항목만 렌더링 한다.
const arr1 = enterpriseData.length < 12 ? enterpriseData : enterpriseData.slice(0, 12);
const itemsToShow = viewAll ? enterpriseData : arr1;
여기서 중요한 포인트는 viewAll이 true일 경우 전체 데이터가 itemsToShow에 담겨 전체 데이터가 렌더링 된다는 점이다.
5. 더보기 버튼 클릭 시 모든 데이터 표시하기
viewAll 상태와 MoreButton을 통해 전체 데이터를 표시할지 여부를 제어할 수 있다. MoreButton을 클릭하면 moreButton 함수가 호출되며 viewAll이 true로 설정된다. 이렇게 설정되면 itemsToShow가 전체 enterpriseData로 바뀌면서 모든 데이터가 렌더링 된다. 또한, loadMoreItems 함수가 호출되며 추가 데이터를 로딩하는 과정도 시작된다.
const moreButton = () => { setViewAll(true); loadMoreItems(); };
위 코드를 통해 viewAll이 true로 설정되면 화면에 추가 항목이 표시되기 시작한다. 이로 인해 처음에는 제한된 데이터만 보이지만, 사용자가 더보기를 원할 때 전체 데이터를 한꺼번에 보여줄 수 있다.
6. 리턴문에서 컴포넌트 구성 및 조건부 렌더링
컴포넌트는 MoreButton과 LoadingWrapper를 조건에 따라 렌더링 한다. viewAll이 true가 아닐 때는 데이터의 길이가 12개보다 많을 경우에만 MoreButton이 표시되어, 필요한 상황에서만 더보기를 허용한다.
<BaseContainer ref={listRef}>
{itemsToShow.slice(0, visibleItems).map((item) => (
<EnterprisesItem key={item.enterprise_id} {...item} />
))}
{viewAll && visibleItems < enterpriseData.length && (
<div ref={setTarget}>
<LoadingWrapper>
<LoadingSpinner></LoadingSpinner>
<LoadingText>🤖 기업정보를 불러오는 중 입니다...</LoadingText>
</LoadingWrapper>
</div>
)}
</BaseContainer>
<ButtonWrapper>
{!viewAll && enterpriseData.length > 12 && (
<MoreButton onClick={moreButton}>더보기</MoreButton>
)}
</ButtonWrapper>
이 부분에서 중요한 점은, viewAll이 활성화되었을 때 LoadingWrapper가 표시되어 추가 데이터를 로딩 중임을 시각적으로 보여주는 UX를 제공한다는 점이다. 또한 MoreButton을 눌러야만 전체 데이터를 확인할 수 있게 하여, 사용자 경험을 개선하고 불필요한 리소스 사용을 방지하는 것이 특징이다.
2. 결과 및 소스코드
1. 결과화면
2. 전체 코드
- LIST
import React, { useContext, useState, useRef, useEffect } from 'react';
import EnterprisesItem from './EnterprisesItem';
import { useNavigate } from 'react-router-dom';
import { CompanyContext } from '../../../App';
import styled from 'styled-components';
import {
LoadingSpinner,
LoadingText,
LoadingWrapper,
} from '../../../components/CommonStyled';
const CompanyList = ({ data, searchNull, EnterpriseData }) => {
const nav = useNavigate();
const [searchKeyword, setSearchKeyword] = useState('');
const [visibleItems, setVisibleItems] = useState(10);
const listRef = useRef();
const [viewAll, setViewAll] = useState(false);
const [enterpriseData, setEnterpriseData] = useState(
Array.isArray(data) && data.length > 0 ? data : EnterpriseData
);
const arr1 =
enterpriseData.length < 12 ? enterpriseData : enterpriseData.slice(0, 12);
const itemsToShow = viewAll ? enterpriseData : arr1;
const changeInput = (e) => {
setSearchKeyword(e.target.value);
};
const loadMoreItems = () => {
setVisibleItems((prevVisibleItems) => prevVisibleItems + 8);
};
const observer = useRef(
new IntersectionObserver(
(entries) => {
const first = entries[0];
if (first.isIntersecting) {
loadMoreItems();
}
},
{ threshold: 1 }
)
);
const [target, setTarget] = useState(null);
useEffect(() => {
const currentElement = target;
const currentObserver = observer.current;
return () => {
if (currentElement) {
currentObserver.unobserve(currentElement);
}
};
}, [target]);
useEffect(() => {
if (viewAll && target) {
observer.current.observe(target);
}
}, [viewAll, target]);
const moreButton = () => {
setViewAll(true);
loadMoreItems();
};
// enterpriseData가 변경될 때마다 재설정
useEffect(() => {
setEnterpriseData(
Array.isArray(data) && data.length > 0 ? data : EnterpriseData
);
}, [data, EnterpriseData]);
return (
<>
<BaseContainer ref={listRef}>
{itemsToShow.slice(0, visibleItems).map((item) => (
<EnterprisesItem key={item.enterprise_id} {...item} />
))}
{viewAll && visibleItems < enterpriseData.length && (
<div ref={setTarget}>
<LoadingWrapper>
<LoadingSpinner></LoadingSpinner>
<LoadingText>🤖 기업정보를 불러오는 중 입니다...</LoadingText>
</LoadingWrapper>
</div>
)}
</BaseContainer>
<ButtonWrapper>
{!viewAll && enterpriseData.length > 12 && (
<MoreButton onClick={moreButton}>더보기</MoreButton>
)}
</ButtonWrapper>
</>
);
};
export default CompanyList;
const BaseContainer = styled.div`
margin-top: 10px;
width: 100%;
max-width: 1200px;
display: flex;
flex-wrap: wrap;
gap: 10px;
justify-content: space-around;
`;
const ButtonWrapper = styled.div`
margin: 30px 0px;
width: 100%;
display: flex;
justify-content: center;
align-items: center;
`;
const MoreButton = styled.button`
width: 100px;
height: 45px;
border: none;
border-radius: 15px;
box-shadow: 0 15px 35px rgba(0, 0, 0, 0.2);
font-weight: 600;
font-size: 16px;
transition: 0.25s;
display: flex;
justify-content: center;
align-items: center;
background-color: aliceblue;
color: var(--primary-color);
&:hover {
letter-spacing: 2px;
transform: scale(1.2);
cursor: pointer;
}
&:active {
transform: scale(1.5);
}
`;
- ITEM
import React from 'react';
import { useNavigate, useParams } from 'react-router-dom';
import styled, { keyframes } from 'styled-components';
import useEmpty from '../../../hooks/useEmpty';
import { FadeInContainer } from '../../../components/FadeIn';
const EnterpriseItem = ({
photo,
name,
address1,
address2,
enterprise_id,
type,
}) => {
const nav = useNavigate();
const parms = useParams();
const isObjEmpty = useEmpty(parms);
if (!photo) {
photo = 'https://cdn-icons-png.flaticon.com/512/4091/4091968.png'; //회사 아이콘 제작자: xnimrodx - Flaticon
}
const clickHandler = () => {
if (isObjEmpty) {
nav(`/Company/${enterprise_id}`);
} else {
nav(`/Search/${parms.keyword}/${enterprise_id}`);
}
};
return (
<FadeInContainer>
<Container onClick={clickHandler}>
<CompanyImg $photo={photo} />
<InfoName>{name}</InfoName>
<Info> 분류 | {type}</Info>
<Info>
주소 | {address1} {address2}
</Info>
</Container>
</FadeInContainer>
);
};
export default EnterpriseItem;
const Container = styled.div`
margin: 20px 10px;
padding: 10px 12px;
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
border-radius: 15px;
width: 200px;
cursor: pointer;
transition: all 0.3s ease-in-out;
border: 0px solid #58c179;
max-width: 200px;
vertical-align: middle;
&:hover {
border: 3px solid #58c179;
transform: scale(1.1);
}
`;
const CompanyImg = styled.div`
width: 100%;
height: 70px;
background-size: contain;
background-position: center;
background-repeat: no-repeat;
background-image: url(${(props) => props.$photo});
`;
const InfoName = styled.div`
font-size: 18px;
font-weight: bold;
margin-top: 12px;
max-width: 200px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
`;
const Info = styled.div`
margin-top: 8px;
`;
- LOADING
export const LoadingWrapper = styled.div`
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
width: 130px;
gap: 10px;
`;
const rotate = keyframes`
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
`;
const opacity = keyframes`
0% {
opacity: 0;
}
50% {
opacity: 1;
}
100% {
opacity: 0;
}
`;
export const LoadingText = styled.div`
white-space: nowrap;
animation: ${opacity} 2s linear infinite;
`;
export const LoadingSpinner = styled.div`
width: 40px;
height: 40px;
border: 5px solid #3498db;
border-top: 5px solid transparent;
border-radius: 50%;
animation: ${rotate} 1s linear infinite;
`;
'React.js > React 프로젝트 및 구현' 카테고리의 다른 글
[React.js] 페이지 있는 게시판 리스트 만들기 (styled-components) (1) | 2024.12.15 |
---|---|
[React.js] 리액트 반응형 구현 (styled-component,media query, grid를 곁들인) (2) | 2024.11.12 |
[React.js] 리액트 글자 타이핑 효과 구현 (직접 구현/전체 코드) (0) | 2024.10.27 |
[React.js] 리액트 원페이지 스크롤 구현 (직접 구현/스크롤 유도) (4) | 2024.10.27 |
[React.js] 구글 페이지 번역 (전체 번역 및 언어 선택 버튼) (4) | 2024.10.18 |