무한 스크롤을 구현하기 위해 라이브러리를 하나 설치한다.
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개 만들었다.
...
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 컴포넌트에는 특정 프롭스들을 필수로 전달해야한다.
<p>Loading</p>
)<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;
}
}
`;