일반적으로 무한 스크롤을 사용하는 경우는 한 페이지 내에서 많은 데이터를 fetch 해야 할 때 사용한다.
무한 스크롤은 사용자의 스크롤이 fetch 해놓은 데이터의 끝(하단)에 도달하게 되면 새로운 데이터를 불러와 추가해주는 UX 방식이다.
Infinite scroll과 비슷한 방법인 pagenation에서도 알아보자.
모든 데이터를 한 번에 불러오고 나서 나열하게 된다면 밑으로 끝없이 쌓이게 된다. 이때 사용자에게 보여줄 데이터의 개수를 정해놓고 page를 넘기듯 리스트를 보여주는 것이 pagenation이다. 대표적으로 우리가 자주 쓰는 구글도 하단에 pagenation을 사용한걸 볼 수 있다.
Youtube, Instagram같은 Feed를 사용자에게 보여줄 때 사용자들은 빠르게 스크롤을 내리면서 많은 콘텐츠가 보이길 기대한다. 사용자는 다음 콘텐츠를 보기 위한 추가 조작이 없고 페이지의 로드 시간이 짧다. 한 페이지에서 많은 양의 콘텐츠를 보여줄 수 있는 장점이 있다. 개인 프로젝트의 Feed에 좋은 사용자 경험을 주기 위해 무한 스크롤을 채택했다.
라이브러리나 API를 사용하기 전에 JS로 기본적인 동작을 이해하기 위해서 DOM의 scroll을 이용해서 간단하게 구현하고 많이 사용하는 Intersection Observer로 구현해보려 한다.
단순하게 Scroll Event를 이용하면 scrollTop, offsetHeight는 reflow가 발생한다. 이를 보완해서 나온 Web API인 Intersection Observer를 2번째 방법에서 소개한다.
//scroll events
const FeedPage = () => {
const [posts, setPosts] = useState([]);
const [isFetching, setIsFetching] = useState(false); // 하단에 도달하면 fetch상태
const fetchPost = useCallback( async() => {
try {
const response = await fetch('/posts');
const data = await response.json();
setPosts([...posts, ...data]);
setIsFetching(false);
} catch(err) {}
}, [])
useEffect(() => {
const clientScroll = () => {
const { scrollTop, offsetHeight } = document.docuementElement;
if( window.innerHeight + scrollTop >= offsetHeight && !isFetching ) {
setIsFetching(true);
}
}
window.addEventListener('scroll', clientScroll);
return () => window.removeEventListener('scroll', clientScroll);
}, [isFetching])
useEffect(() => {
if( isFetching ) fetchPost();
}, [isFetching])
return(
<Layout>
{posts.map((post)) => (
<Card key={post.id} title={post.title} />
)}
{isFetching && <Loading />}
</Layout>
)
}
Intersection Observer는 기본적으로 브라우저 Viewport와 설정한 Element의 교차점을 관찰하며, 요소가 뷰포트에 포함되는지 포함되지 않는지, 더 쉽게는 사용자 화면에 지금 보이는 요소인지 아닌지를 구별하는 기능을 제공한다.
이 기능은 비동기적으로 실행되기 때문에, scroll 같은 이벤트 기반의 요소 관찰에서 발생하는 렌더링 성능이나 이벤트 연속 호출 같은 문제 없이 사용할 수 있다.
글만 읽고 나면 무슨 말인지 이해하기 어려울 수 있는데, 그림과 같이 보면 한 번에 이해할 수 있다.
인스턴스를 생성하고 Element를 지정해 관찰할 수 있다.
const io = new IntersectionObserver(callback, options);
io.observer(element)
관찰할 대상이 등록되거나 가시성에 변화가 생기면 callback이 실행된다. callback의 인수는 entries, observer로 구성되어있다.
const io = new IntersectionObserver((entries, observer) => {}, options);
entries, observer, options의 자세한 설명
정말 양질의 포스팅을 하시는 Heropy님의 블로그입니다.
2번에서 설명했던 Intersection Observer를 그대로 사용할 수 있고 무엇보다 쉽다 Hook 형태로 만들어놓은 라이브러리라고 보면 될 것 같다.
npm i react-intersection-observer
import { useInView } from 'react-intersection-observer';
const Page = () => {
const [ref, InView] = useInView(options);
}
ref는 위의 element, target과 같은 역할을 하고 해당 요소를 관찰하고 변화가 생기면 InView가 true(기본값은 false)가 된다. 처음 Scroll Event에서 작성했던 isFetch를 InView의 상태와 같다고 보면 된다.
JS의 fetch(), async await를 action으로 대체하고 현재 관찰하고 있는 ref를 통해서 dispatch하면 된다.
//store initialState
const initialState = {
posts = [];
scroll = true;
}
//actions
const postAction = createAsyncThunk(
'postAction', async(_, { rejectWithValue }) => {
try {
//page대신 lastId 방식
const response = await axios.get('/posts?lastId={lastId || 0}');
return response.data;
} catch(err) {
//에러 처리
}
}
)
//modules
const postSlice = createSlice({
name: 'post',
initialState,
reducers: {},
extraReducers: (builder) => {
builder
.addCase(postAction.fulfilled, (state, action) => {
state.posts = state.posts.concat(action.payload);
state.scroll = !!포스트의 개수
})
},
})
//infinite scroll page
import { postAction } from 'action';
const Page = () => {
const dispatch = useDispatch();
const { posts, scroll } = useSelector(state => state.post);
const [ ref, InView] = useInView({
threshold: 0,
});
useEffect(() => {
if( InView && scroll ) {
const lastId = posts.at(-1).id;
dispatch( postAction(lastId) );
}
}, [InView, scroll])
return(
<>
{posts.map(post => (
<Card key={post.id} title={post.title} />
))}
<div ref={ref} style={{marginTop: 10}}/>
</>
)
}
https://heropy.blog/2019/10/27/intersection-observer/
https://velog.io/@heelieben/JavaScript-Reflow-%EB%9E%80-feat.-%EB%B8%8C%EB%9D%BC%EC%9A%B0%EC%A0%80-%EB%A0%8C%EB%8D%94%EB%A7%81
https://www.npmjs.com/package/react-intersection-observer#useinview-hook
https://developer.mozilla.org/en-US/docs/Web/API/Intersection_Observer_API