구현 방법은 아래와 같습니다.
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 |
댓글 영역