페이지네이션 로직에 관한 문서.
뒤로 가기 및 새로고침 등 웹에서 어떤 변화가 생겨도 페이지 상태가 그대로 유지되기 위해 현재 페이지, 페이지 당 데이터 개수 상태들을 가져와 URL의 Parameters로 관리하는 구조로 만듦.
useRouter
), 현재 경로(usePathname
), 및 URL 파라미터(useSearchParams
)를 사용해 URL을 가져오거나 수정.updateQueryString
함수를 통해 URL 파라미터를 갱신하고, 페이지 및 항목 수를 처리하는 여러 함수들이 정의.handlePagination
: 기본값이 1인 currentPage
를 변경하는 함수.handlePerPage
: 기본값이 10인 itemsPerPage
를 변경하는 함수.// 실사용 예시는 pagination 컴포넌트에 있음, 드롭다운을 선택해 필터를 거는 로직이나 searchText 검색하는 로직 등 page=1로 초기화 돼야하는 부분에는 초기화 진행하기.
import { usePathname, useRouter, useSearchParams } from 'next/navigation';
/**
* 이 훅은 페이지네이션 URL 파라미터를 변경하는데 사용.
* 기본적으로 perPage는 10, page는 1로 설정.
*/
const useChangeParamsPage = () => {
const router = useRouter();
const pathname = usePathname();
const searchParams = useSearchParams();
// Next.JS App Router Updating searchParams example 참고
// https://nextjs.org/docs/app/api-reference/functions/use-search-params#updating-searchparams
const updateQueryString = (name: string, value: string) => {
// 가지고 있는 searchParams를 다시 URL 객체로 초기화
const params = new URLSearchParams(searchParams);
// set : 있는 name을 value로 갱신(append는 추가가 되므로 사용 X)
params.set(name, value);
return params.toString();
};
// 기본값 설정
const itemsPerPage = searchParams.get('perPage') || '10';
const currentPage = searchParams.get('page') || '1';
/**
* 페이지 번호를 변경하고 해당 페이지로 이동하는 함수
* @param {string} pageNumber - 이동할 페이지 번호
*/
const handlePagination = (pageNumber: string) => {
// pageNumber에 숫자가 들어오지 않을 시 예외처리
if (isNaN(parseInt(pageNumber))) {
router.push(pathname + '?' + updateQueryString('page', '1'));
return;
}
router.push(pathname + '?' + updateQueryString('page', pageNumber));
};
/**
* 한 페이지에 표시될 아이템 개수를 변경하고 해당 페이지로 이동하는 함수
* @param {'10' | '30' | '50'} perPage - 변경할 페이지당 아이템 개수
*/
const handlePerPage = (perPage: '10' | '30' | '50') => {
handlePagination('1');
// perPage에 숫자가 들어오지 않을 시 예외처리
if (isNaN(parseInt(perPage))) {
router.push(pathname + '?' + updateQueryString('perPage', '10'));
return;
}
router.push(pathname + '?' + updateQueryString('perPage', perPage));
};
// 반환
return { itemsPerPage, currentPage, handlePagination, handlePerPage };
};
export default useChangeParamsPage;
handlePrevClick
및 handleNextClick
함수는 이전/다음 페이지로 이동하게 하는 기능.onPageChange
)을 호출. 여기에 handlePagination
이 들어감.// Next.JS App Router Updating searchParams example 참고
// https://nextjs.org/docs/app/api-reference/functions/use-search-params#updating-searchparams
interface PaginationComponentProps {
// 페이지 수정 함수
onPageChange: (pageNumber: string) => void;
// 총 페이지
totalItems: number | undefined;
// 페이지 당 보일 데이터 개수(perPage)
itemsPerPage: string;
// 현재 페이지(start)
currentPage: string;
}
/**
* PaginationComponent는 페이지를 나타내는 컴포넌트
* @param onPageChange - 페이지 변경 시 호출되는 콜백 함수
* @param totalItems - 전체 항목 수를 나타내며, 기본값은 0
* @param itemsPerPage - 페이지당 항목 수를 나타냄
* @param currentPage - 현재 선택된 페이지의 번호
*/
export default function PaginationComponent({
onPageChange,
totalItems = 0,
itemsPerPage,
currentPage,
}: PaginationComponentProps) {
// 전체 페이지 수 계산
const pageCount = Math.ceil(totalItems / parseInt(itemsPerPage));
// 페이지 번호 배열 생성
const pages = [...Array(pageCount)]?.map((_, i) => i + 1);
// 블록은 각 번호 Section을 뜻함. 총 페이지가 19페이지라면 1,2,3,4,5가 한 블럭 ~ 16,17,18,19가 한 블럭.
// 블록은 5 페이지 단위로 구성되며, 현재 페이지가 속한 블록의 첫 페이지와 마지막 페이지를 기준으로 이전과 다음 페이지를 계산함.
// 블록의 첫 번째 페이지 계산
const blockStart = Math.floor((parseInt(currentPage) - 1) / 5) * 5;
/**
* 이전 버튼 클릭 시 호출되는 핸들러 함수. 이전 버튼을 누를 시 새 블록 지점으로 가면서 그 블록의 제일 첫번째 페이지로 이동.
* 현재 페이지가 5 이상인 경우 이전 블록의 끝 페이지로 이동.
* 현재 페이지가 5 이하인 경우 1페이지로 이동.
*/
const handlePrevClick = () => {
const prevPage = Math.max(1, blockStart);
onPageChange(`${prevPage}`);
};
/**
* 다음 버튼 클릭 시 호출되는 핸들러 함수. 다음 버튼을 누를 시 새 블록 지점으로 가면서 그 블록의 제일 마지막 페이지로 이동.
* 현재 페이지가 (전체 페이지 수 - 5)보다 작은 경우 다음 5의 배수 페이지로 이동.
* 그렇지 않으면 마지막 페이지로 이동.
*/
const handleNextClick = () => {
const nextPage = Math.min(blockStart + 6, pageCount);
onPageChange(`${nextPage}`);
};
return (
<nav className='flex items-center justify-center' aria-label='Pagination'>
<button
onClick={handlePrevClick}
className='bg-gray-outline mr-1 h-[25px] w-10 rounded text-xs font-medium transition-all hover:bg-opacity-70'
>
이전
</button>
{pages
?.slice((Math.ceil(parseInt(currentPage) / 5) - 1) * 5, Math.ceil(parseInt(currentPage) / 5) * 5)
.map((pageNumber) => (
<span
key={pageNumber}
className={`mr-1 h-[1.5625rem] w-[1.5625rem] cursor-pointer rounded text-xs font-medium ${
`${pageNumber}` === currentPage
? 'bg-emerald-5 cursor-default text-white'
: 'bg-gray-outline transition-all hover:bg-opacity-70'
} flex items-center justify-center`}
onClick={() => onPageChange(`${pageNumber}`)}
>
{pageNumber}
</span>
))}
<button
onClick={handleNextClick}
className='bg-gray-outline h-[25px] w-10 rounded text-xs font-medium transition-all hover:bg-opacity-70'
>
다음
</button>
</nav>
);
}
fetchAuth
함수 및 useQuery
훅을 사용해 GET 요청, QueryString을 보내는 등의 서버와의 통신을 처리.useChangeParamsPage
훅을 사용해 페이지 및 항목 수 변수인 currentPage
, itemsPerPage
처리.PaginationComponent
를 렌더링하고, 페이지 변경에 따라 데이터를 다시 가져오도록 함(리액트쿼리의 refetchTxnBook
)./// BookManage 컴포넌트
export default function BookManageContainer() {
const searchParams = useSearchParams();
const vendorId = searchParams.get('vendorId');
const {
isLoading,
isError,
data,
refetch: refetchTxnBook,
} = useQuery<TxnBookResponse>({
queryKey: ['txnBookList'],
queryFn: () =>
fetchAuth({
url: '/customers/txnbooks',
options: {
queryParams: {
start: currentPage,
perPage: itemsPerPage,
startDate: startDateFormat as string,
endDate: endDateFormat as string,
[txnBookCateKey]: txnBookCateValue,
vendorId,
qs: searchedText,
},
},
}),
});
const txnBookData = data?.payload?.items ?? [];
// Pagination
const totalItems = data?.payload?.pgn?.total ?? 0;
const { itemsPerPage, currentPage, handlePagination, handlePerPage } = useChangeParamsPage();
// Reset Button 핸들러
const handleResetButton = () => {
handlePagination('1');
handlePerPage('10');
setSelectPerPage('10건 씩 보기');
setStartDate(null);
setEndDate(null);
setSelectCate('');
setTxnBookCateValue(undefined);
setSearchedText('');
setSearchTextInput('');
};
// itemPerPage 드롭다운 상태 및 핸들러
const [selectedPerPage, setSelectPerPage] = useState('10건 씩 보기');
const handleSelectPerPage = (selected: string) => {
if (selected === '10건 씩 보기') {
setSelectPerPage('10건 씩 보기');
handlePerPage('10');
}
if (selected === '30건 씩 보기') {
setSelectPerPage('30건 씩 보기');
handlePerPage('30');
}
if (selected === '50건 씩 보기') {
setSelectPerPage('50건 씩 보기');
handlePerPage('50');
}
handlePagination('1');
};
...
// refetch
useEffect(() => {
refetchTxnBook();
}, [refetchTxnBook, itemsPerPage, currentPage, startDate, endDate, selectedCate, vendorId, searchedText]);
...
totalItems
), 현재 페이지 범위 등을 표시하는 부분.PaginationComponent
를 렌더링하여 페이지 확인 onPageChange
에 handlePagination
을 넣어 이동 요청을 가능케 함....
{/* 표 푸터 */}
<div className='static bottom-0 flex h-[2.81rem] w-full items-center justify-between bg-white pl-12 pr-4'>
<div className='text-xs font-medium text-emerald-gray-2'>
{txnBookData
? `${totalItems || 0}개 중 ${parseInt(itemsPerPage) * (parseInt(currentPage) - 1) + 1} - ${
parseInt(itemsPerPage) * (parseInt(currentPage) - 1) + txnBookData?.length
}`
: '0개 중 0 - 0'}
</div>
<div>
<PaginationComponent
onPageChange={handlePagination}
totalItems={totalItems}
itemsPerPage={itemsPerPage}
currentPage={currentPage}
/>
</div>
</div>
필터 상태 등 전체 데이터 수가 변경되거나 페이지를 초기화하면 좋을 로직에 handlePagination('1')
를 작성해 초기화가 되도록 함.
그 아래는 초기화가 필요한 상태들.
handleResetButton
함수itemPerPage
가 변경될 때 페이지 초기화qs
에 제출할 때(handleSearch
함수) 초기화handleSelected~
함수) 초기화// 1. Reset 핸들러 함수인 `handleResetButton` 함수
const handleResetButton = () => {
handlePagination('1');
handlePerPage('10');
setSelectPerPage('10건 씩 보기');
setStartDate(null);
setEndDate(null);
setSelectCate('');
setTxnBookCateValue(undefined);
setSearchedText('');
setSearchTextInput('');
};
// 2. `itemPerPage`가 변경될 때 페이지 초기화
const [selectedPerPage, setSelectPerPage] = useState('10건 씩 보기');
const handleSelectPerPage = (selected: string) => {
if (selected === '10건 씩 보기') {
setSelectPerPage('10건 씩 보기');
handlePerPage('10');
}
if (selected === '30건 씩 보기') {
setSelectPerPage('30건 씩 보기');
handlePerPage('30');
}
if (selected === '50건 씩 보기') {
setSelectPerPage('50건 씩 보기');
handlePerPage('50');
}
handlePagination('1');
};
// 3. 검색 로직 중 검색 쿼리인 `qs`에 제출할 때(`handleSearch` 함수) 초기화
const [searchTextInput, setSearchTextInput] = useState('');
const [searchedText, setSearchedText] = useState('');
const handleSearchInput = (e: ChangeEvent<HTMLInputElement>) => {
setSearchTextInput(e.target.value);
if (e.target.value === '') {
setSearchedText('');
}
};
const handleSearch = () => {
// 검색어에서 공백을 +로 치환하는 로직
const sanitizedSearchText = searchTextInput.split(' ').join('+');
setSearchedText(sanitizedSearchText);
// 페이지네이션 초기화
handlePagination('1');
};
const handleKeyDown = (e: KeyboardEvent<HTMLInputElement>) => {
if (e.key === 'Enter') {
handleSearch();
}
};
// 4. 모든 드롭다운 필터가 변경될 때(`handleSelected~` 함수) 초기화
const handleSelectedBranch = (selected: string) => {
setSelectedBranch(selected);
const selectedBranchId = orgBranchListData?.find((branchItem) => branchItem?.name === selected)?.branchId || '';
handlePagination('1');
setBranchId(selectedBranchId);
};
const [selectedPosition, setSelectedPosition] = useState<string>('');
const positionNameArr = orgPositionListData?.map((positionItem) => {
return positionItem?.name;
});
const [positionId, setPositionId] = useState<string>('');
const handleSelectedPosition = (selected: string) => {
setSelectedPosition(selected);
const selectedPositionId =
orgPositionListData?.find((positionItem) => positionItem?.name === selected)?.positionId || '';
handlePagination('1');
setPositionId(selectedPositionId);
};