구현 방법은 아래와 같습니다.
atomFamily를 이용하여 scroll 정보와 리스트 item 정보를 관리합니다.
const todoGroupListSelector = atomFamily<ITodoGroup[], string>({
key: 'todoGroupList',
default: (key: string) => atom({ key: `todoGroupList_${key}`, default: [] }),
});
interface ScrollInfo {
page: number;
hasMore: boolean;
}
const scrollInfoSelector = atomFamily<ScrollInfo, string>({
key: 'todoGroupList/scrollInfo',
default: (key: string) =>
atom<ScrollInfo>({
key: `todoGroupList/scrollInfo${key}`,
default: {
page: 0,
hasMore: true,
},
}),
});
...
const [scrollInfo, setScrollInfo] = useRecoilState<ScrollInfo>(scrollInfoSelector(recoilKey));
const [items, setItems] = useRecoilState<ITodoGroup[]>(todoGroupListSelector(key));
2. useQuery를 사용하여 server data 관리
const { isLoading, isRefetching, data } = useQuery([query.name, page], () => query(page), {
enabled: page > 0,
});
3. ref array를 이용하여 각 element를 관리하여 최적화 관리
const itemRefs = useRef<MutableRefObject<HTMLDivElement | null>[]>([]);
const [isLoad, setIsLoad] = useState(false); // 초기 element rendering여부 판단
const ScrollItem = ({ item, index, rendering}: {item: unknown; index: number; rendering: boolean;}): JSX.Element => {
const el = itemCopmonent({ item });
if (!itemRefs.current[index]) {
itemRefs.current[index] = React.createRef();
}
const ref = itemRefs.current[index];
useEffect(() => {
if (!isLoad) setIsLoad(true);
}, [ref]);
return React.cloneElement(el, { ref: itemRefs.current[index], rendering });
};
return (
<>
{items.map((item, index) => {
return (
<ScrollItem key={index} item={item} index={index} rendering={isRendering(itemRefs.current[index]?.current)} />
);
})}
<div ref={loader} />
</>
);
4. 최적화 Rendering 여부 판단
const useCalcViewport = (itemRefs: MutableRefObject<HTMLElement | null>[] | null, root: HTMLElement | null) => {
const [scrollTop, setScrollTop] = useState(0);
useEffect(() => {
const onscroll = (e: Event) => {
const top = ((e.target as Document).scrollingElement as HTMLElement).scrollTop;
setScrollTop(top);
};
window.addEventListener('scroll', onscroll);
return () => {
window.removeEventListener('scroll', onscroll);
};
}, [root]);
const isRendering = (item: HTMLElement | null): boolean => {
const scrollHeight = window.document?.scrollingElement?.clientHeight;
if (item && scrollHeight) {
const top = item.offsetTop;
const h = item.offsetHeight;
return scrollTop <= top + h && scrollTop + scrollHeight >= top;
} else {
return false;
}
};
return isRendering;
};
export default useCalcViewport;
최종 Code
main.tsx
const Item = ({ item }: { item: ITodoGroup }) => {
return (
<TodoGroupItem
data-testid="todo-group-item"
rendering={false}
key={item.todoGroupId}
item={item}
onClick={() => navigate(`/todo/${item.todoGroupId}`, { state: item })}
/>
);
};
const next = (addedItems: ITodoGroup[], _page: number) => {
if (addedItems) {
setItems([...items, ...addedItems]);
}
};
return (
<>
<InfiniteScroll
query={query}
itemCopmonent={Item}
scrollElement={scrollElement?.current || null}
next={next}
items={items}
recoilKey={recoilKey}
/>
</>
);
InfiniteScroll.tsx
const defaultOptions = {
root: null,
rootMargin: '0px',
threshold: 1.0,
};
interface ScrollInfo {
page: number;
hasMore: boolean;
}
const scrollInfoSelector = atomFamily<ScrollInfo, string>({
key: 'todoGroupList/scrollInfo',
default: (key: string) =>
atom<ScrollInfo>({
key: `todoGroupList/scrollInfo${key}`,
default: {
page: 0,
hasMore: true,
},
}),
});
interface IInfiniteScroll {
// eslint-disable-next-line
itemCopmonent: ({ item }: { item: any;}) => JSX.Element;
root?: Document | null;
query: (_p: number) => Promise<unknown[]>;
scrollElement: HTMLDivElement | null;
next: (_items: [], _page: number) => void;
items: unknown[];
recoilKey: string;
}
export default function InfiniteScroll({
itemCopmonent,
query,
scrollElement,
next,
items,
recoilKey,
}: IInfiniteScroll) {
const totalPage = 2; // temporarily
const [scrollInfo, setScrollInfo] = useRecoilState<ScrollInfo>(scrollInfoSelector(recoilKey));
const { page, hasMore } = scrollInfo;
const oldPage = useRef(page);
const loader = useRef(null);
const observer = useRef<IntersectionObserver>();
const itemRefs = useRef<MutableRefObject<HTMLDivElement | null>[]>([]);
const [isLoad, setIsLoad] = useState(false); // 초기 로드 완료 구분
const { isLoading, isRefetching, data } = useQuery([query.name, page], () => query(page), {
enabled: page > 0,
});
const isRendering = useCalcViewport([], scrollElement);
useEffect(() => {
if (!isRefetching && !isLoading && page > 0 && data && page > oldPage.current) {
next(data as [], page);
oldPage.current = oldPage.current + 1;
if (page > totalPage || data.length === 0) {
setScrollInfo({ ...scrollInfo, hasMore: false });
}
}
// eslint-disable-next-line
}, [isLoading, isRefetching]);
useEffect(() => {
const handleObserver = ([target]: IntersectionObserverEntry[]) => {
if (target.isIntersecting && hasMore) {
setScrollInfo({ ...scrollInfo, page: oldPage.current + 1 });
}
};
if (!observer.current) {
observer.current = new IntersectionObserver(handleObserver, { ...defaultOptions });
const current = loader.current;
current && observer.current?.observe(current);
return () => {
current && observer.current?.unobserve(current);
};
}
// eslint-disable-next-line
}, [hasMore]);
const ScrollItem = ({
item,
index,
rendering,
}: {
item: unknown;
index: number;
rendering: boolean;
}): JSX.Element => {
const el = itemCopmonent({ item });
if (!itemRefs.current[index]) {
itemRefs.current[index] = React.createRef();
}
const ref = itemRefs.current[index];
useEffect(() => {
if (!isLoad) setIsLoad(true);
}, [ref]);
return React.cloneElement(el, { ref: itemRefs.current[index], rendering });
};
return (
<>
{items.map((item, index) => {
return (
<ScrollItem key={index} item={item} index={index} rendering={isRendering(itemRefs.current[index]?.current)} />
);
})}
<div ref={loader} />
</>
);
}
useCalcViewport.ts
const useCalcViewport = (itemRefs: MutableRefObject<HTMLElement | null>[] | null, root: HTMLElement | null) => {
const [scrollTop, setScrollTop] = useState(0);
useEffect(() => {
const onscroll = (e: Event) => {
const top = ((e.target as Document).scrollingElement as HTMLElement).scrollTop;
setScrollTop(top);
};
window.addEventListener('scroll', onscroll);
return () => {
window.removeEventListener('scroll', onscroll);
};
}, [root]);
const isRendering = (item: HTMLElement | null): boolean => {
const scrollHeight = window.document?.scrollingElement?.clientHeight;
if (item && scrollHeight) {
const top = item.offsetTop;
const h = item.offsetHeight;
return scrollTop <= top + h && scrollTop + scrollHeight >= top;
} else {
return false;
}
};
return isRendering;
};
export default useCalcViewport;
[React] Function Component (0) | 2024.01.12 |
---|---|
[React] Infinite scroll (1) - React query (0) | 2023.03.08 |
[React] Recoil Selector (0) | 2023.03.01 |
React Query - useQuery 맛보기 (0) | 2023.03.01 |
React 조건부 Rendering시 주의점 (0) | 2023.03.01 |
댓글 영역