이전에 무한스크롤을 구현했으나, 스크롤이 없으면 기능이 제대로 작동하지 않는 에러를 발견했다. 디폴트로 10개씩의 아티스트 리스트를 불러오는데, 20개를 불러오는것으로 간단하게 해결하고 싶은 마음이 들 정도로 쌩고생을 했는데😭 결국 해냈다.
ref=null
로 할당되는 것으로 보였다. useEffect의 의존성배열에 target을 넘겨 mount되었을때 ref가 설정되도록 하고 target이 업데이트될 때 감지할 수 있도록 했으나 작동되지 않았다.useRef의 객체는 바뀌지 않기 때문에 target.current 값이 바뀌었다고 해도 useEffect가 변화를 감지하고 값을 업데이트하지 않았다.
useRef는 읽기전용이어서 업데이트되지도 않고, 리렌더링되지 않고, 재할당할 수 없다..
야속하게도 초기 렌더링되고 다시 접근하면 swr의 캐싱 기능 때문에 동작하면서 필자를 약올리기까지 했다... useCallback도 사용해보고 list에 ref를 주려고 이리저리 노력해보다 리액트 공식문서도 보고 mdn도 보고,,, 하다가 로딩 컴포넌트를 만들기로 했다.
그래서 문제를 다시 정의해봤다.
- 타겟은 충실하게 가장 마지막 요소로 할당되고있음.
- 첫번째 타겟이 화면에 노출되어야 다음 페이지가 나오고 있음.
- 현재 타겟이 화면에 노출되어있고, 페이지가 남아있을 경우 다음 페이지가 나오도록 해야함!
타겟이 계속해서 업데이트되어야하고, 타겟이 화면에 나타났는지 여부가 확인되어야하다보니 계속해서 늘어나는 list 컴포넌트가 아니라 list 뒤에 로딩 컴포넌트를 두고 타겟을 고정시키는 것이 낫겠다는 판단이 들었다. 어차피 다음 컨텐츠가 로딩되고있다는 사실을 나타내는 것이 좋기도 할테고.
import useSWRInfinite from "swr/infinite";
import axios from "axios";
import { RefObject, useEffect } from "react";
interface InfiniteScrollParam<T> {
apiUrl: string;
target: RefObject<HTMLDivElement>;
}
interface ScrolledData<T> {
isLoading: boolean;
scrolledData: T[];
isNextPage: boolean;
}
const useInfiniteScroll = <T>({
apiUrl,
target,
}: InfiniteScrollParam<T>): ScrolledData<T> => {
const getKey = (page: number, previousPageData: any) => {
if (previousPageData && !previousPageData.length) {
//끝에 도달함
return null;
}
if (page === 0) return `${apiUrl}?page=1&size=10`;
return `${apiUrl}?page=${page + 1}&size=10`;
};
const fetcher = async (url: string) => {
const res = await axios.get(url);
return res.data;
};
const { data, isLoading, setSize } = useSWRInfinite<T>(
(page, previousPageData) => getKey(page, previousPageData),
fetcher,
{ parallel: true }
);
const scrolledData = data ? ([] as T[]).concat(...data) : [];
const isNextPage = data ? data && data[data.length - 1].length > 0 : false;
console.log(data && data[data.length - 1], isNextPage);
useEffect(() => {
let options = {
root: null,
rootMargin: "0px",
threshold: 0.5,
};
const observer = new IntersectionObserver((entries) => {
const lastEntry = entries[0];
if (lastEntry && lastEntry.isIntersecting) {
setSize((prevSize) => prevSize + 1);
}
}, options);
//(단축평가) ref는 항상 존재여부를 검사 후 사용.
target.current && observer.observe(target.current);
if (target.current) {
observer.observe(target.current);
}
return () => {
if (target.current) {
observer.unobserve(target.current);
}
};
}, [setSize, target]);
return { isLoading, scrolledData, isNextPage };
};
export default useInfiniteScroll;
import useInfiniteScroll from "@/hooks/useInfiniteScroll";
import ArtistCard from "./molecules/ArtistCard";
import { ArtistData } from "./organisms/ArtistList";
import { useRef } from "react";
import Loading from "./common/Loading";
const ArtistInfiniteScroll = () => {
const target = useRef<HTMLDivElement>(null);
const { isLoading, scrolledData, isNextPage } = useInfiniteScroll<ArtistData>(
{
apiUrl: "/api/artist",
target,
}
);
return (
<ul className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-8">
{scrolledData?.map((artist) => (
<li key={artist._id}>
<ArtistCard artist={artist} />
</li>
))}
{(isLoading || isNextPage) && <Loading ref={target} />}
</ul>
);
};
export default ArtistInfiniteScroll;
useEffect의 cleanup 함수는 effect 함수 내에서 사용된 변수들의 최신 값을 유지하지 않습니다. 대신 cleanup 함수가 호출될 때의 변수 값들을 사용합니다.
따라서 observer.unobserve(target.current)를 cleanup 함수로 사용할 경우, cleanup 함수가 호출될 때 target.current의 최신 값이 아닌 이전 값이 사용될 수 있습니다. 이는 observer.unobserve를 호출하는 시점에서 target.current가 이미 변경되었을 때 문제가 될 수 있습니다.
이를 방지하기 위해 target.current 값을 별도로 변수에 할당한 후 cleanup 함수에 사용하면, cleanup 함수가 호출될 때 해당 변수의 값이 유지됩니다. 이렇게 하면 항상 최신의 target.current 값을 observer.unobserve에 전달할 수 있습니다.
unmount동작을 할때 target.current를 넘겨주었더니 에러는 아니지만 알람 문구가 떠서 찾아보았다. 아주그냥 ref를 톺아보는 시간을 갖는구나.
useEffect(() => {
let options = {
root: null,
rootMargin: "0px",
threshold: 0.5,
};
const currentTarget = target.current; // 현재 target DOM을 변수에 담았다.
const observer = new IntersectionObserver((entries) => {
const lastEntry = entries[0];
if (lastEntry && lastEntry.isIntersecting) {
setSize((prevSize) => prevSize + 1);
}
}, options);
target.current && observer.observe(target.current);
if (currentTarget) {
observer.observe(currentTarget);
}
return () => {
if (currentTarget) {
observer.unobserve(currentTarget);
}
};
}, [setSize, target]);