저번 게시글에서는 간단하게 데이터를 나열하는 테이블을 만들어봤습니다.
이번 게시글에서는 react-table
에서 제공하는 페이지네이션을 활용해 페이지네이션을 구현하는 방법을 살펴보겠습니다.
처음 고민했던 내용은 페이지네이션의 위치였습니다.
간단하게 구현할 수 있는 것은 클라이언트 (브라우저) 에서 자바스크립트 코드를 활용해 페이지네이션 하는 것 이지만,
백오피스의 성격 상 데이터가 무수히 많을텐데, 무수히 많은 데이터를 클라이언트에서 페이지네이션 처리하는것은 리소스 낭비가 크지 않을까 생각했습니다.
결국 서버에서 페이지네이션해서 데이터를 내려받고 그대로 UI 에 꽂아주는 형태로 개발을 하였지만, 결국은 클라이언트에서 페이지네이션 하는 것도 고려해야 했기 때문에 boolean
값을 통해서 클라이언트, 서버 페이지네이션을 선택할 수 있도록 구현했습니다.
먼저 서버사이드 페이지네이션을 구현해봅시다. UI 는 MUI 의 TablePagination
컴포넌트를 활용할 것 입니다.
페이지 당 데이터의 갯수, 페이지의 Index 등 데이터가 변할 때 마다 서버에 새로 요청을 하고 데이터를 보내줘야 합니다.
서버, react-table, MUI TablePagination 을 묶어서 구현하려니 아주 복잡한게 아닙니다.
서버 페이지네이션을 위해 사용할 pagination hook 을 만듭니다.
import { PaginationState } from '@tanstack/react-table';
import { Dispatch, SetStateAction, useCallback, useState } from 'react';
type TablePaginationHookReturnType = {
pagination: PaginationState;
setPagination: Dispatch<SetStateAction<PaginationState>>;
onChangePageIndex: (pageIndx: number) => void;
onChangePageSize: (pageIndx: number) => void;
initializePageIndex: () => void;
};
const useTablePagination = (initialState?: PaginationState): TablePaginationHookReturnType => {
const [pagination, setPagination] = useState<PaginationState>(
initialState ?? {
pageIndex: 0,
pageSize: 25,
},
);
const initializePageIndex = useCallback(() => {
setPagination((prev) => ({ ...prev, pageIndex: 0 }));
}, []);
const handleChangePageIndex = useCallback((pageIndex: number) => {
setPagination((prev) => ({ ...prev, pageIndex }));
}, []);
const handleChangePageSize = useCallback((pageSize: number) => {
setPagination((prev) => ({ ...prev, pageSize }));
}, []);
return {
pagination,
setPagination,
initializePageIndex,
onChangePageIndex: handleChangePageIndex,
onChangePageSize: handleChangePageSize,
};
};
export default useTablePagination;
훅을 호출할 때 initialState
을 통해서 pageIndex
, pageSize
를 초기화 할 수 있습니다. 리턴되는 객체는 페이지단과 테이블 컴포넌트에 전달해줄 값들을 담고 있습니다.
테이블 컴포넌트를 수정해봅시다. 먼저 해당 값들을 props 로 전달받아야 합니다.
export type TableProps<T> = {
name: string;
data: T[];
columns: ColumnDef<T>[];
noDataMessage?: string;
// pagination
usePagination?: boolean;
useClientPagination?: boolean;
onChangePageIndex?: (pageIndex: number) => void;
onChangePageSize?: (pageSize: number) => void;
pageCount?: number;
pagination?: PaginationState;
setPagination?: Dispatch<SetStateAction<PaginationState>>;
};
프로퍼티명이 훅의 리턴 타입과 일치하기 때문에 어떤 값을 넣어야 하는지 명확하게 알 수 있습니다.
해당 프로퍼티를 useReactTable
훅의 매개변수 옵션에 넣어줍니다.
먼저 페이지네이션 자체를 서버에 위임하여 react-table 이 자동으로 페이지네이션을 해주지 않기 때문에 manualPagination
을 true
로 설정해야 합니다.
그리고 props 로 전달받은 pagination
, setPagination
값들을 각각 initialState.pagination
, onPaginationChange
에 넣어줍니다.
const table = useReactTable<T>({
data,
columns,
getCoreRowModel: getCoreRowModel(),
manualPagination: true,
onPaginationChange: setPagination,
initialState: {
pagination,
},
});
props 로 전달받은 onChangePageIndex
, onChangePageSize
함수는 MUI 의 TablePagination
에 핸들러로 전달할 함수에서 값을 넣어서 실행시켜 줍니다.
const handleChangePage = useCallback(
(event: React.MouseEvent<HTMLButtonElement, MouseEvent> | null, page: number) => {
if (onChangePageIndex) {
onChangePageIndex(page);
}
},
[onChangePageIndex],
);
const handleChangeRowsPerPage = useCallback(
(event: React.ChangeEvent<HTMLInputElement>) => {
if (onChangePageSize) {
onChangePageSize(Number(event.target.value));
}
},
[onChangePageSize],
);
마지막으로 TablePagination
에 넣어줄 props 를 선언해주고 해당 값을 TablePagination
에 넘겨줍니다.
const tablePaginationProps: TablePaginationProps = {
component: 'div',
count: data.length,
labelRowsPerPage: '페이지 당 행의 개수',
onPageChange: handleChangePage,
rowsPerPageOptions: [
{ label: '5', value: 5 },
{ label: '10', value: 10 },
{ label: '25', value: 25 },
{ label: '50', value: 50 },
{ label: '100', value: 100 },
],
onRowsPerPageChange: handleChangeRowsPerPage,
page: pagination?.pageIndex ?? 0,
rowsPerPage: pagination?.pageSize ?? 0,
};
{usePagination && <TablePagination {...tablePaginationProps} />}
이렇게 구현을 하고나서 api 콜을 할때마다 pageSize
, pageIndex
를 전달해주고 페이지에 해당되는 값을 응답받아 테이블에 넘겨줍니다. 그럼 서버 페이지네이션은 완성입니다.
import { TablePagination, TablePaginationProps } from '@mui/material';
import {
ColumnDef,
getCoreRowModel,
useReactTable,
flexRender,
PaginationState,
} from '@tanstack/react-table';
import styled from '@emotion/styled';
import { Dispatch, SetStateAction, useCallback } from 'react';
export type TableProps<T> = {
name: string;
data: T[];
columns: ColumnDef<T>[];
noDataMessage?: string;
// pagination
usePagination?: boolean;
useClientPagination?: boolean;
onChangePageIndex?: (pageIndex: number) => void;
onChangePageSize?: (pageSize: number) => void;
pageCount?: number;
pagination?: PaginationState;
setPagination?: Dispatch<SetStateAction<PaginationState>>;
};
function Table<T>(props: TableProps<T>) {
const {
usePagination = true,
data,
columns,
noDataMessage,
pagination,
setPagination,
onChangePageIndex,
onChangePageSize,
} = props;
const table = useReactTable<T>({
data,
columns,
getCoreRowModel: getCoreRowModel(),
manualPagination: true,
onPaginationChange: setPagination,
initialState: {
pagination,
},
});
const { getHeaderGroups, getRowModel, getState, setPageIndex, setPageSize } = table;
const isNoData = getRowModel().rows.length === 0;
const handleChangePage = useCallback(
(event: React.MouseEvent<HTMLButtonElement, MouseEvent> | null, page: number) => {
if (onChangePageIndex) {
onChangePageIndex(page);
}
},
[onChangePageIndex],
);
const handleChangeRowsPerPage = useCallback(
(event: React.ChangeEvent<HTMLInputElement>) => {
if (onChangePageSize) {
onChangePageSize(Number(event.target.value));
}
},
[onChangePageSize],
);
const tablePaginationProps: TablePaginationProps = {
component: 'div',
count: data.length,
labelRowsPerPage: '페이지 당 행의 개수',
onPageChange: handleChangePage,
rowsPerPageOptions: [
{ label: '5', value: 5 },
{ label: '10', value: 10 },
{ label: '25', value: 25 },
{ label: '50', value: 50 },
{ label: '100', value: 100 },
],
onRowsPerPageChange: handleChangeRowsPerPage,
page: pagination?.pageIndex ?? 0,
rowsPerPage: pagination?.pageSize ?? 0,
};
return (
<TableContainer>
{getHeaderGroups().map((headerGroup) => (
<TableHeader className="row">
{headerGroup.headers.map((header) =>
header.isPlaceholder ? null : (
<TableCell key={header.id} width={header.column.getSize()}>
{flexRender(header.column.columnDef.header, header.getContext())}
</TableCell>
),
)}
</TableHeader>
))}
<TableBody>
{isNoData ? (
<NoDataComponent>{noDataMessage}</NoDataComponent>
) : (
getRowModel().rows.map((row) => (
<TableRow className="row">
{row.getVisibleCells().map((cell) => (
<TableCell key={cell.id} width={cell.column.getSize()}>
{flexRender(cell.column.columnDef.cell, cell.getContext())}
</TableCell>
))}
</TableRow>
))
)}
</TableBody>
{usePagination && <TablePagination {...tablePaginationProps} />}
</TableContainer>
);
}
export default Table;
처음 고민했던 내용 중 하나가 서버 페이지네이션이 굳이 필요하지 않는 경우입니다.
현재 제가 작업중인 프로젝트는 자주 변하지 않는 데이터는 서버에서 하나의 리스트로 내려주고 있습니다.
이런 경우에서도 공통된 UI 를 위해 페이지네이션을 적용해야 하는 상황을 마주했고, 이를 위해서 클라이언트 페이지네이션을 추가했습니다.
props 에 useClientPagination
이라는 값을 추가했고, 클라이언트 페이지네이션이 필요한 경우 해당 값에 true 를 넣어줍니다.
useClientPagination?: boolean;
클라이언트 페이지네이션은 해당 기능 자체를 react-table 에 위임합니다. pagination 을 위해서 getPaginationRowModel
함수를 라이브러리에서 import 하고 option 으로 넣어줍니다. 또한 초기 상태를 지정해줍니다. pageIndex
는 0 부터 시작입니다.
const table = useReactTable<T>({
data,
columns,
getCoreRowModel: getCoreRowModel(),
...(usePagination &&
(useClientPagination
? {
getPaginationRowModel: getPaginationRowModel(),
initialState: {
pagination: {
pageIndex: 0,
pageSize: 25,
},
},
}
: {
manualPagination: true,
onPaginationChange: setPagination,
initialState: {
pagination,
},
})),
});
테이블 인스턴스에서 getState
, setPageIndex
, setPageSize
함수들을 꺼내줍니다. 해당 값들을 통해서 MUI 페이지네이션 컴포넌트와 react-table 을 연동할 수 있습니다.
해당 함수들은 서버 페이지네이션과 동일한 방식으로 사용됩니다.
const handleChangePage = useCallback(
(event: React.MouseEvent<HTMLButtonElement, MouseEvent> | null, page: number) => {
if (!useClientPagination && onChangePageIndex) {
onChangePageIndex(page);
} else if (useClientPagination) {
setPageIndex(page);
}
},
[onChangePageIndex, setPageIndex, useClientPagination],
);
const handleChangeRowsPerPage = useCallback(
(event: React.ChangeEvent<HTMLInputElement>) => {
if (!useClientPagination && onChangePageSize) {
onChangePageSize(Number(event.target.value));
} else if (useClientPagination) {
setPageSize(Number(event.target.value));
}
},
[onChangePageSize, setPageSize, useClientPagination],
);
마지막으로 MUI TablePagination
에 전달할 props 를 수정해줍니다. page
와 rowsPerPage
에 들어가는 값은 테이블 인스턴스로부터 꺼내온 getState
함수를 통해 넣어줄 수 있습니다.
const tablePaginationProps: TablePaginationProps = {
component: 'div',
count: data.length,
labelRowsPerPage: '페이지 당 행의 개수',
onPageChange: handleChangePage,
rowsPerPageOptions: [
{ label: '5', value: 5 },
{ label: '10', value: 10 },
{ label: '25', value: 25 },
{ label: '50', value: 50 },
{ label: '100', value: 100 },
],
onRowsPerPageChange: handleChangeRowsPerPage,
...(useClientPagination
? {
page: getState().pagination.pageIndex,
rowsPerPage: getState().pagination.pageSize,
}
: {
page: pagination?.pageIndex ?? 0,
rowsPerPage: pagination?.pageSize ?? 0,
}),
};
import { TablePagination, TablePaginationProps } from '@mui/material';
import {
ColumnDef,
getCoreRowModel,
useReactTable,
flexRender,
PaginationState,
getPaginationRowModel,
} from '@tanstack/react-table';
import styled from '@emotion/styled';
import { Dispatch, SetStateAction, useCallback } from 'react';
export type TableProps<T> = {
name: string;
data: T[];
columns: ColumnDef<T>[];
noDataMessage?: string;
// pagination
usePagination?: boolean;
useClientPagination?: boolean;
onChangePageIndex?: (pageIndex: number) => void;
onChangePageSize?: (pageSize: number) => void;
pageCount?: number;
pagination?: PaginationState;
setPagination?: Dispatch<SetStateAction<PaginationState>>;
};
function Table<T>(props: TableProps<T>) {
const {
usePagination = true,
useClientPagination = false,
data,
columns,
noDataMessage,
pagination,
setPagination,
onChangePageIndex,
onChangePageSize,
} = props;
const table = useReactTable<T>({
data,
columns,
getCoreRowModel: getCoreRowModel(),
...(usePagination &&
(useClientPagination
? {
getPaginationRowModel: getPaginationRowModel(),
initialState: {
pagination: {
pageIndex: 0,
pageSize: 25,
},
},
}
: {
manualPagination: true,
onPaginationChange: setPagination,
initialState: {
pagination,
},
})),
});
const { getHeaderGroups, getRowModel, getState, setPageIndex, setPageSize } = table;
const isNoData = getRowModel().rows.length === 0;
const handleChangePage = useCallback(
(event: React.MouseEvent<HTMLButtonElement, MouseEvent> | null, page: number) => {
if (!useClientPagination && onChangePageIndex) {
onChangePageIndex(page);
} else if (useClientPagination) {
setPageIndex(page);
}
},
[onChangePageIndex, setPageIndex, useClientPagination],
);
const handleChangeRowsPerPage = useCallback(
(event: React.ChangeEvent<HTMLInputElement>) => {
if (!useClientPagination && onChangePageSize) {
onChangePageSize(Number(event.target.value));
} else if (useClientPagination) {
setPageSize(Number(event.target.value));
}
},
[onChangePageSize, setPageSize, useClientPagination],
);
const tablePaginationProps: TablePaginationProps = {
component: 'div',
count: data.length,
labelRowsPerPage: '페이지 당 행의 개수',
onPageChange: handleChangePage,
rowsPerPageOptions: [
{ label: '5', value: 5 },
{ label: '10', value: 10 },
{ label: '25', value: 25 },
{ label: '50', value: 50 },
{ label: '100', value: 100 },
],
onRowsPerPageChange: handleChangeRowsPerPage,
...(useClientPagination
? {
page: getState().pagination.pageIndex,
rowsPerPage: getState().pagination.pageSize,
}
: {
page: pagination?.pageIndex ?? 0,
rowsPerPage: pagination?.pageSize ?? 0,
}),
};
return (
<TableContainer>
{getHeaderGroups().map((headerGroup) => (
<TableHeader className="row">
{headerGroup.headers.map((header) =>
header.isPlaceholder ? null : (
<TableCell key={header.id} width={header.column.getSize()}>
{flexRender(header.column.columnDef.header, header.getContext())}
</TableCell>
),
)}
</TableHeader>
))}
<TableBody>
{isNoData ? (
<NoDataComponent>{noDataMessage}</NoDataComponent>
) : (
getRowModel().rows.map((row) => (
<TableRow className="row">
{row.getVisibleCells().map((cell) => (
<TableCell key={cell.id} width={cell.column.getSize()}>
{flexRender(cell.column.columnDef.cell, cell.getContext())}
</TableCell>
))}
</TableRow>
))
)}
</TableBody>
{usePagination && <TablePagination {...tablePaginationProps} />}
</TableContainer>
);
}
export default Table;
const TableContainer = styled.div`
width: 100%;
height: 100%;
display: flex;
flex-direction: column;
font-size: 14px;
.row {
width: 100%;
display: flex;
border-bottom: 1px solid rgba(224, 224, 224, 1);
}
`;
const TableCell = styled.div<{ width: number }>`
width: ${({ width }) => width}px;
color: rgba(0, 0, 0, 0.87);
display: flex;
align-items: center;
word-break: break-all;
`;
const TableRow = styled.div`
&:hover {
background-color: rgba(0, 0, 0, 0.04);
}
`;
const TableHeader = styled.div`
font-weight: 500;
`;
const TableBody = styled.div`
display: flex;
flex-direction: column;
`;
const NoDataComponent = styled.div`
width: 100%;
display: flex;
justify-content: center;
align-items: center;
`;
페이지네이션을 구현하기 위해 몇일을 고민했었는데 결국은 강력한 react-table 을 통해서 쉽게 구현할 수 있었습니다. 공식문서를 살펴보면 페이지네이션에 대한 예제가 잘 되어있어서 보다 쉽게 구현하실 수 있을것 같습니다.
다음 포스팅에서는 테이블에서 행을 선택, 확장, 정렬을 구현했던 과정을 담아보도록 하겠습니다.
감사합니다.