[React] Infinite scroll (json-server, axios, react-infinite-scroll-component)

Hwanhoon KIM·2023년 8월 1일
0

무한 스크롤을 구현하기 위해 라이브러리를 하나 설치한다.

npm i react-infinite-scroll-component 또는

yarn add react-infinite-scroll-component

weekly downloads가 50만회가 넘는걸로 봐서 상당히 인기가 있음을 알 수 있다.

먼저 무한스크롤에 맞는 로직을 먼저 만들자.

useQuery로 json-server에 다음과 같은 요청을 한다.

export const getTodoDB = async (page: number): Promise<any> => {
  return await requestDB(`/database?_limit=5&_page=${page}`);
};

컴포넌트에서 다음과 같이 세팅한다. <InfiniteScroll>컴포넌트 안에 data.map()메소드를 사용하여 JSX를 리턴한다.

import InfiniteScroll from 'react-infinite-scroll-component';
...
return
...
<InfiniteScroll></InfiniteScroll>

state를 3개 만들었다.

  1. wholeTodoData: 받아온 모든 데이터를 합친 state이다.
  2. currentPage: 현재 페이지이다. (number)
  3. hasMore: 다음 페이지에 데이터가 있는지 확인한다. (boolean)
...
const [wholeTodoData, setWholeTodoData] = useState<T_todoList>([]);
const [currentPage, setCurrentPage] = useState<number>(1);
const [hasMore, setHasMore] = useState<boolean>(true);
...

useQuery를 통해 불러온 data를 currentPageData로 변수명을 지정한다음에, setWholeTodoData()함수를 통해 저장해둔다.

...
const { data: currentPageData } = useQuery({
    queryKey: ['todoList', currentPage],
    queryFn: () => getTodoDB(currentPage),
    keepPreviousData: true,
  });
...
useEffect(() => {
    if (!!currentPageData?.data.length) {
      setWholeTodoData((prev) => {
        return [...prev, ...currentPageData.data];
      });
    }
  }, [currentPageData]);

이제 wholeTodoData가 채워졌으니 데이터를 표시한다.

...
return
...
<InfiniteScroll>
          {wholeTodoData?.map((todo): JSX.Element => {
            return (
              <TodoContainerS key={todo.id}>
                <TodoContentS>{todo.todo}</TodoContentS>
                <ButtonContainerS>
                  <button onClick={() => editClickHandler(todo.id)}>
                    수정
                  </button>
                  <button onClick={() => deleteModalToggleHandler(todo.id)}>
                    삭제
                  </button>
                </ButtonContainerS>
              </TodoContainerS>
            );
          })}
        </InfiniteScroll>
...

사실 InfiniteScroll 컴포넌트에는 특정 프롭스들을 필수로 전달해야한다.

  1. dataLength: (필수) 데이터의 길이를 적는다.
  2. next: (필수) 데이터의 길이를 향해 스크롤이 내려갈 때 실행할 함수를 적는다.(추가로드)
  3. hasMore: (필수) 다음 페이지의 데이터가 있는지 boolean값을 적는다
  4. loader: (필수) 로딩중이라는 JSX를 작성한다. 예시(<p>Loading</p>)
  5. scrollableTarget: (선택) 전체화면에 대한 스크롤이 아닐 경우, 해당 div의 id를 따로 작성해준다.
  6. endMessage: (필수) 데이터 로드가 끝나면 표시할 JSX를 작성한다. 예시(<p>Yay! You have seen it all</p>)
...
<InfiniteScroll
  dataLength={wholeTodoData.length}
  next={() => setCurrentPage(currentPage + 1)}
  hasMore={hasMore}
  loader={<p>Loading</p>}
  scrollableTarget={'scroll-target'}
  endMessage={
    <p style={{ textAlign: 'center' }}>Yay! You have seen it all</p>
  }
>
...

이제 hasMore를 true, false로 바꾸기위해 다음 페이지를 미리 로드해서 데이터를 확인한 후, 데이터가 비어있으면 false, 데이터가 있으면 true로 바꾸는 로직을 만든다.

const { data: nextPageData } = useQuery({
  queryKey: ['todoList', currentPage + 1],
  queryFn: () => getTodoDB(currentPage + 1),
  keepPreviousData: true,
});

useEffect(() => {
  if (!!nextPageData?.data.length) {
    setHasMore(true);
  } else {
    setHasMore(false);
  }
}, [nextPageData]);

이제 나름(?) 쉽게 무한 스크롤을 구현할 수 있다.

전체코드 확인하기

import React, { useEffect, useState } from 'react';
import { styled } from 'styled-components';
import { T_todoList } from '../../../redux/modules/todo';
import { deleteTodoDB, editTodoDB, getTodoDB } from '../../../axios/dbApi';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import InfiniteScroll from 'react-infinite-scroll-component';

const TodoList: React.FC<{}> = () => {
  // queryClient 인스턴스 초기화
  const queryClient = useQueryClient();
  const [wholeTodoData, setWholeTodoData] = useState<T_todoList>([]);
  const [currentPage, setCurrentPage] = useState<number>(1);
  const [hasMore, setHasMore] = useState<boolean>(true);

  const { data: currentPageData } = useQuery({
    queryKey: ['todoList', currentPage],
    queryFn: () => getTodoDB(currentPage),
    keepPreviousData: true,
  });
  // nextPageData
  const { data: nextPageData } = useQuery({
    queryKey: ['todoList', currentPage + 1],
    queryFn: () => getTodoDB(currentPage + 1),
    keepPreviousData: true,
  });

  useEffect(() => {
    if (!!nextPageData?.data.length) {
      setHasMore(true);
    } else {
      setHasMore(false);
    }
  }, [nextPageData]);

  useEffect(() => {
    if (!!currentPageData?.data.length) {
      setWholeTodoData((prev) => {
        return [...prev, ...currentPageData.data];
      });
    }
  }, [currentPageData]);

  const [confirmToDelete, setConfirmToDelete] = useState<boolean>(false);
  const [deleteModalToggler, setDeleteModalToggler] = useState<
    [boolean, string] | null
  >(null);

  //
  // 수정하기 query 로직
  const editQuery = useMutation({
    mutationFn: editTodoDB,
    onSuccess: async (): Promise<void> => {
      await queryClient.invalidateQueries({ queryKey: ['todoList'] });
    },
  });
  // 수정하기
  const editClickHandler = (id: string): void => {
    const newTodo: string | null | undefined =
      prompt('수정할 값을 입력해주세요.');
    if (!newTodo) {
      return;
    }
    const targetIndex: number = wholeTodoData.findIndex(
      (todo) => todo.id === id
    );
    // deepcopy of todoDB
    const copiedDB: T_todoList = structuredClone(wholeTodoData);
    copiedDB[targetIndex].todo = newTodo;
    // editTodoDB({ id, todo: newTodo });
    editQuery.mutate({ id, todo: newTodo });
  };
  //
  //
  // 삭제 모달 토글러
  const deleteModalToggleHandler = (id: string): void => {
    if (deleteModalToggler && deleteModalToggler[0]) {
      return;
    } else {
      setDeleteModalToggler([true, id]);
    }
  };

  // 삭제하기 query 로직
  const deleteQuery = useMutation({
    mutationFn: deleteTodoDB,
    onSuccess: async (): Promise<void> => {
      await queryClient.invalidateQueries({ queryKey: ['todoList'] });
    },
  });
  // 삭제 하기
  useEffect((): void => {
    if (
      !confirmToDelete ||
      deleteModalToggler === null ||
      wholeTodoData === null
    ) {
      return;
    }
    const id = deleteModalToggler[1];
    deleteQuery.mutate(id);
    setDeleteModalToggler(null);
    setConfirmToDelete(false);
  }, [confirmToDelete, deleteModalToggler, deleteQuery, wholeTodoData]);

  // 삭제 확인 모달
  const ConfirmToDeleteModal = () => {
    return (
      <DeleteModal>
        <p>정말 삭제하시겠습니까?</p>
        <div>
          <button onClick={() => setConfirmToDelete(true)}>삭제</button>
          <button onClick={() => setDeleteModalToggler(null)}>취소</button>
        </div>
      </DeleteModal>
    );
  };
  //

  return (
    <>
      {deleteModalToggler && <ConfirmToDeleteModal />}
      <TodoListContainerS id="scroll-target">
        <InfiniteScroll
          dataLength={wholeTodoData.length}
          next={() => setCurrentPage(currentPage + 1)}
          hasMore={hasMore}
          loader={<p>Loading</p>}
          scrollableTarget={'scroll-target'}
          endMessage={
            <p style={{ textAlign: 'center' }}>Yay! You have seen it all</p>
          }
        >
          {wholeTodoData?.map((todo): JSX.Element => {
            return (
              <TodoContainerS key={todo.id}>
                <TodoContentS>{todo.todo}</TodoContentS>
                <ButtonContainerS>
                  <button onClick={() => editClickHandler(todo.id)}>
                    수정
                  </button>
                  <button onClick={() => deleteModalToggleHandler(todo.id)}>
                    삭제
                  </button>
                </ButtonContainerS>
              </TodoContainerS>
            );
          })}
        </InfiniteScroll>
      </TodoListContainerS>
    </>
  );
};

export default TodoList;

const TodoListContainerS = styled.div`
  width: 90%;
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: 0.5rem;
  padding: 1rem;
  height: 400px;
  overflow: scroll;
`;

const TodoContainerS = styled.div`
  display: flex;
  align-items: center;
  justify-content: space-between;
  box-shadow: 1px 1px 3px salmon;
  width: 300px;
  margin-bottom: 1rem;
  min-height: 5rem;
  border-radius: 5px;
`;

const TodoContentS = styled.p`
  flex: 1 1 auto;
  padding: 0 0.4rem;
  font-size: 0.9rem;
`;

const ButtonContainerS = styled.div`
  display: flex;
  gap: 3px;
  height: 100%;
  & > button {
    cursor: pointer;
  }
`;

const DeleteModal = styled.div`
  display: flex;
  flex-direction: column;
  gap: 1rem;
  position: absolute;
  width: 200px;
  height: 100px;
  border: 1px salmon solid;
  top: 50%;
  left: 50%;
  transform: translate(-50%, -50%);
  justify-content: center;
  align-items: center;
  & > div {
    width: 100%;
    display: flex;
    justify-content: center;
    gap: 0.3rem;
    & > * {
      width: 40%;
      padding: 3px;
      cursor: pointer;
    }
  }
`;
profile
Fullstack Developer, I post about HTML, CSS(SASS, LESS), JavaScript, React, Next, TypeScript.

0개의 댓글