상세 컨텐츠

본문 제목

[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' 카테고리의 다른 글

[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

관련글 더보기

댓글 영역