상세 컨텐츠

본문 제목

[React] Infinite scroll(2) - React query + Recoil + 최적화

React

by 일단두잇 2023. 3. 8. 16:08

본문

반응형

 

구현 방법은 아래와 같습니다.

 

  1. atomFamily를 이용하여 state 관리
  2. useQuery를 사용하여 server data 관리
  3. ref array를 이용하여 각 element를 관리하여 최적화 관리
  4. 최적화 Rendering 여부 판단

 

 

 

  1. atomFamily를 이용하여 state 관리

 

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 관리

 

  • key를 page로 설정하여 page가 변경될때마다 api를 호출합니다.
  • enabled 옵션은 초기 scroll이벤트를 대응하기 위해 page를 0을 설정하고 이후 scroll이벤트에 따라서 api를 호출합니다. (scroll 이벤트는 초기에 무조건 발생합니다.)
const { isLoading, isRefetching, data } = useQuery([query.name, page], () => query(page), {
enabled: page > 0,
});
 

3. ref array를 이용하여 각 element를 관리하여 최적화 관리

 

  • rendering여부 판단을 위해 ref array를 생성합니다.
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 여부 판단

 

  • scroll Event 설정
  • scroll 위치에 따라서 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' 카테고리의 다른 글

관련글 더보기

댓글 영역