react-table 활용해 테이블 만들기 -2

Thomas·2023년 10월 15일
2
post-thumbnail

👐 Hello Pagination!

저번 게시글에서는 간단하게 데이터를 나열하는 테이블을 만들어봤습니다.
이번 게시글에서는 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 이 자동으로 페이지네이션을 해주지 않기 때문에 manualPaginationtrue 로 설정해야 합니다.
그리고 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 를 수정해줍니다. pagerowsPerPage 에 들어가는 값은 테이블 인스턴스로부터 꺼내온 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;
`;

🚪< Quit />

페이지네이션을 구현하기 위해 몇일을 고민했었는데 결국은 강력한 react-table 을 통해서 쉽게 구현할 수 있었습니다. 공식문서를 살펴보면 페이지네이션에 대한 예제가 잘 되어있어서 보다 쉽게 구현하실 수 있을것 같습니다.
다음 포스팅에서는 테이블에서 행을 선택, 확장, 정렬을 구현했던 과정을 담아보도록 하겠습니다.
감사합니다.

profile
안녕하세요! 주니어 웹 개발자입니다 😆

0개의 댓글