Search code examples
reactjstypescriptspring-bootmaterial-uireact-typescript

Implement pagination for React Material table


I have this Spring Boot endpoint for listing items from database:

import React, { useEffect, useState } from "react";

import clsx from "clsx";
import {
  createStyles,
  lighten,
  makeStyles,
  Theme,
} from "@material-ui/core/styles";
import CircularProgress from "@material-ui/core/CircularProgress";
import Table from "@material-ui/core/Table";
import TableBody from "@material-ui/core/TableBody";
import TableCell from "@material-ui/core/TableCell";
import TableContainer from "@material-ui/core/TableContainer";
import TableHead from "@material-ui/core/TableHead";
import TablePagination from "@material-ui/core/TablePagination";
import TableRow from "@material-ui/core/TableRow";
import TableSortLabel from "@material-ui/core/TableSortLabel";
import Toolbar from "@material-ui/core/Toolbar";
import Typography from "@material-ui/core/Typography";
import Paper from "@material-ui/core/Paper";
import Checkbox from "@material-ui/core/Checkbox";
import IconButton from "@material-ui/core/IconButton";
import Tooltip from "@material-ui/core/Tooltip";
import DeleteIcon from "@material-ui/icons/Delete";
import FilterListIcon from "@material-ui/icons/FilterList";
import axios, { AxiosResponse } from "axios";
import { getTask } from "../../service/merchants";

const baseUrl = "http://185.185.126.15:8080/api";

interface OnboardingTaskDto {
  id?: number;
  name: string;
}

async function getTask(
  page: number,
  size: number
): Promise<AxiosResponse<OnboardingTaskDto[]>> {
  return await axios.get<OnboardingTaskDto[]>(
    `${baseUrl}/management/onboarding/task?page=${page}&size=${size}`
  );
}

interface Data {
  id: number;
  businessName: string;
  title: string;
  status: string;
}

function createData(
    id: number,
    businessName: string,
    title: string,
    status: string
): Data {
  return { id, businessName, title, status };
}

function descendingComparator<T>(a: T, b: T, orderBy: keyof T) {
  if (b[orderBy] < a[orderBy]) {
    return -1;
  }
  if (b[orderBy] > a[orderBy]) {
    return 1;
  }
  return 0;
}

type Order = "asc" | "desc";

function getComparator<Key extends keyof any>(
    order: Order,
    orderBy: Key
): (
    a: { [key in Key]: number | string },
    b: { [key in Key]: number | string }
) => number {
  return order === "desc"
      ? (a, b) => descendingComparator(a, b, orderBy)
      : (a, b) => -descendingComparator(a, b, orderBy);
}

function stableSort<T>(array: T[], comparator: (a: T, b: T) => number) {
  const stabilizedThis = array.map((el, index) => [el, index] as [T, number]);
  stabilizedThis.sort((a, b) => {
    const order = comparator(a[0], b[0]);
    if (order !== 0) return order;
    return a[1] - b[1];
  });
  return stabilizedThis.map((el) => el[0]);
}

interface HeadCell {
  disablePadding: boolean;
  id: keyof Data;
  label: string;
  numeric: boolean;
}

const headCells: HeadCell[] = [
  { id: "id", numeric: false, disablePadding: true, label: "id" },
  {
    id: "businessName",
    numeric: true,
    disablePadding: false,
    label: "businessName",
  },
  { id: "title", numeric: true, disablePadding: false, label: "title" },
  { id: "status", numeric: true, disablePadding: false, label: "status" },
];

interface EnhancedTableProps {
  classes: ReturnType<typeof useStyles>;
  numSelected: number;
  onRequestSort: (
      event: React.MouseEvent<unknown>,
      property: keyof Data
  ) => void;
  onSelectAllClick: (event: React.ChangeEvent<HTMLInputElement>) => void;
  order: Order;
  orderBy: string;
  rowCount: number;
}

function EnhancedTableHead(props: EnhancedTableProps) {
  const {
    classes,
    onSelectAllClick,
    order,
    orderBy,
    numSelected,
    rowCount,
    onRequestSort,
  } = props;
  const createSortHandler =
      (property: keyof Data) => (event: React.MouseEvent<unknown>) => {
        onRequestSort(event, property);
      };

  return (
      <TableHead>
        <TableRow>
          <TableCell padding="checkbox">
            <Checkbox
                indeterminate={
                  numSelected > 0 && numSelected < rowCount
                }
                checked={rowCount > 0 && numSelected === rowCount}
                onChange={onSelectAllClick}
                inputProps={{ "aria-label": "select all desserts" }}
            />
          </TableCell>
          {headCells.map((headCell) => (
              <TableCell
                  key={headCell.id}
                  align={headCell.numeric ? "right" : "left"}
                  padding={headCell.disablePadding ? "none" : "normal"}
                  sortDirection={orderBy === headCell.id ? order : false}
              >
                <TableSortLabel
                    active={orderBy === headCell.id}
                    direction={orderBy === headCell.id ? order : "asc"}
                    onClick={createSortHandler(headCell.id)}
                >
                  {headCell.label}
                  {orderBy === headCell.id ? (
                      <span className={classes.visuallyHidden}>
                                    {order === "desc"
                                        ? "sorted descending"
                                        : "sorted ascending"}
                                </span>
                  ) : null}
                </TableSortLabel>
              </TableCell>
          ))}
        </TableRow>
      </TableHead>
  );
}

const useToolbarStyles = makeStyles((theme: Theme) =>
    createStyles({
      root: {
        paddingLeft: theme.spacing(2),
        paddingRight: theme.spacing(1),
      },
      highlight:
          theme.palette.type === "light"
              ? {
                color: theme.palette.secondary.main,
                backgroundColor: lighten(
                    theme.palette.secondary.light,
                    0.85
                ),
              }
              : {
                color: theme.palette.text.primary,
                backgroundColor: theme.palette.secondary.dark,
              },
      title: {
        flex: "1 1 100%",
      },
    })
);

interface EnhancedTableToolbarProps {
  numSelected: number;
  onClick: (e: React.MouseEvent<unknown>) => void;
}

const EnhancedTableToolbar = (props: EnhancedTableToolbarProps) => {
  const classes = useToolbarStyles();
  const { numSelected } = props;

  return (
      <Toolbar
          className={clsx(classes.root, {
            [classes.highlight]: numSelected > 0,
          })}
      >
        {numSelected > 0 ? (
            <Typography
                className={classes.title}
                color="inherit"
                variant="subtitle1"
                component="div"
            >
              {numSelected} selected
            </Typography>
        ) : (
            <Typography
                className={classes.title}
                variant="h6"
                id="tableTitle"
                component="div"
            >
              Customers
            </Typography>
        )}
        {numSelected > 0 ? (
            <Tooltip title="Delete">
              <IconButton aria-label="delete" onClick={props.onClick}>
                <DeleteIcon />
              </IconButton>
            </Tooltip>
        ) : (
            <Tooltip title="Filter list">
              <IconButton aria-label="filter list">
                <FilterListIcon />
              </IconButton>
            </Tooltip>
        )}
      </Toolbar>
  );
};

const useStyles = makeStyles((theme: Theme) =>
    createStyles({
      root: {
        width: "100%",
      },
      paper: {
        width: "100%",
        marginBottom: theme.spacing(2),
      },
      table: {
        minWidth: 750,
      },
      visuallyHidden: {
        border: 0,
        clip: "rect(0 0 0 0)",
        height: 1,
        margin: -1,
        overflow: "hidden",
        padding: 0,
        position: "absolute",
        top: 20,
        width: 1,
      },
    })
);

export default function BusinessCustomersTable() {
  const classes = useStyles();
  const [order, setOrder] = React.useState<Order>("asc");
  const [orderBy, setOrderBy] = React.useState<keyof Data>("businessName");
  const [selected, setSelected] = React.useState<number[]>([]);
  const [page, setPage] = React.useState(0);
  const [rowsPerPage, setRowsPerPage] = React.useState(5);
  const [rows, setRows] = useState<Data[]>([]);
  const [loading, setLoading] = useState(false);

  let updatedState: Data[] = [];

  // TODO - move this to API file
  const apiUrl = "http://185.185.126.15:8080/api/management/onboarding/task";

  useEffect(() => {
    const getData = async () => {
      setLoading(true);

      getTask(1, 100)
          .then((resp) => {
            console.log(resp.data);
          })
          .catch((error) => {
            console.error(error);
          });
      const response = await axios.get(apiUrl, {
        params: { page: 1, size: 100 },
      });
      setLoading(false);

      const objContent: any = response.data.content;

      for (let a = 0; a < objContent.length; a++) {
        updatedState[a] = createData(
            objContent[a].id,
            objContent[a].businessName,
            objContent[a].title,
            objContent[a].status
        );

        setRows([...rows, ...updatedState]);
      }
    };

    getData();
  }, []);

  const handleRequestSort = (
      event: React.MouseEvent<unknown>,
      property: keyof Data
  ) => {
    const isAsc = orderBy === property && order === "asc";
    setOrder(isAsc ? "desc" : "asc");
    setOrderBy(property);
  };

  const handleSelectAllClick = (
      event: React.ChangeEvent<HTMLInputElement>
  ) => {
    if (event.target.checked) {
      const newSelecteds = rows.map((n) => n.id);
      setSelected(newSelecteds);
      return;
    }
    setSelected([]);
  };

  const handleClick = (event: React.MouseEvent<unknown>, id: number) => {
    const selectedIndex = selected.indexOf(id);
    let newSelected: number[] = [];

    if (selectedIndex === -1) {
      newSelected = newSelected.concat(selected, id);
    } else if (selectedIndex === 0) {
      newSelected = newSelected.concat(selected.slice(1));
    } else if (selectedIndex === selected.length - 1) {
      newSelected = newSelected.concat(selected.slice(0, -1));
    } else if (selectedIndex > 0) {
      newSelected = newSelected.concat(
          selected.slice(0, selectedIndex),
          selected.slice(selectedIndex + 1)
      );
    }

    setSelected(newSelected);
  };

  const handleChangePage = (event: unknown, newPage: number) => {
    setPage(newPage);
  };

  const handleChangeRowsPerPage = (
      event: React.ChangeEvent<HTMLInputElement>
  ) => {
    setRowsPerPage(parseInt(event.target.value, 10));
    setPage(0);
  };

  const handleDeleteClick = async () => {
    // npm install qs
    var qs = require("qs");

    const response = await axios.delete(apiUrl, {
      params: {
        ids: selected,
      },
      paramsSerializer: (params) => {
        return qs.stringify(params);
      },
    });

    if (response.status === 204) {
      const updatedData = rows.filter(
          (row) => !selected.includes(row.id)
      ); // It'll return all data except selected ones

      setRows(updatedData); // reset rows to display in table.
    }
  };

  const isSelected = (id: number) => selected.indexOf(id) !== -1;

  const emptyRows =
      rowsPerPage - Math.min(rowsPerPage, rows.length - page * rowsPerPage);

  return (
      <div className={classes.root}>
        <Paper className={classes.paper}>
          <EnhancedTableToolbar
              numSelected={selected.length}
              onClick={handleDeleteClick}
          />
          <TableContainer>
            <Table
                className={classes.table}
                aria-labelledby="tableTitle"
                aria-label="enhanced table"
            >
              <EnhancedTableHead
                  classes={classes}
                  numSelected={selected.length}
                  order={order}
                  orderBy={orderBy}
                  onSelectAllClick={handleSelectAllClick}
                  onRequestSort={handleRequestSort}
                  rowCount={rows.length}
              />
              <TableBody>
                {loading ? (
                    <div className="spinerr">
                      <CircularProgress />
                    </div>
                ) : null}
                {stableSort(rows, getComparator(order, orderBy))
                    .slice(
                        page * rowsPerPage,
                        page * rowsPerPage + rowsPerPage
                    )
                    .map((row, index) => {
                      const isItemSelected = isSelected(row.id);
                      const labelId = `enhanced-table-checkbox-${index}`;

                      return (
                          <TableRow
                              hover
                              onClick={(event) =>
                                  handleClick(event, row.id)
                              }
                              role="checkbox"
                              aria-checked={isItemSelected}
                              tabIndex={-1}
                              key={row.businessName}
                              selected={isItemSelected}
                          >
                            <TableCell padding="checkbox">
                              <Checkbox
                                  checked={isItemSelected}
                                  inputProps={{
                                    "aria-labelledby":
                                    labelId,
                                  }}
                              />
                            </TableCell>
                            <TableCell
                                component="th"
                                id={labelId}
                                scope="row"
                                padding="none"
                            >
                              {row.id}
                            </TableCell>
                            <TableCell align="right">
                              {row.businessName}
                            </TableCell>
                            <TableCell align="right">
                              {row.title}
                            </TableCell>
                            <TableCell align="right">
                              {row.status}
                            </TableCell>
                          </TableRow>
                      );
                    })}
                {emptyRows > 0 && (
                    <TableRow style={{ height: 53 * emptyRows }}>
                      <TableCell colSpan={6} />
                    </TableRow>
                )}
              </TableBody>
            </Table>
          </TableContainer>
          <TablePagination
              rowsPerPageOptions={[5, 10, 25]}
              component="div"
              count={rows.length}
              rowsPerPage={rowsPerPage}
              page={page}
              onPageChange={handleChangePage}
              onRowsPerPageChange={handleChangeRowsPerPage}
          />
        </Paper>
      </div>
  );
}

Sandbox: https://stackblitz.com/edit/react-ts-tnpk85?file=Hello.tsx

When data is loaded first time and I switch pages I don't see additional requests to Back end. Looks like data table rows data is loaded only once. I need to implement a lazy pagination and load current page data when I switch page. Do you know how I can fix this?


Solution

  • What @NafazBenzema is suggesting will probably work, but you have to keep in mind that by his suggestion you will be doing only frontend pagination, which is most likely not what you wanna do.

    Frontend pagination means that:

    1. You will fire only one request to get ALL elements from the backend. If there are 10.000.000 records, all 10.000.000 of them will be transferred at once (which depending on the scenario, might take quite some time as you may imagine).
    2. After that, no more requests will be fired when you change pages in your table, because you have already fetched all the records.
    3. You will choose which records to show in each page using logic in the frontend (slicing the array, as Nafaz suggested).

    This approach is not necessarily wrong, but it's not ideal either because it's not scalable. Moreover, it seems that you already have backend pagination in place! So you should try to use it 😃

    To do so, you will:

    1. Only fetch the first page of records from the backend. For example, only the first 20 records... even if you had 10.000.000 records in total, only the first 20 will be returned.
    2. Each request should get not only the records in the page, but also a number indicating what's the total amount of records. Continuing with my previous examples, that number would be 10.000.000. This is needed for the table component to know what page to request and how many pages there are in total, without needing to actually fetch all the records from the backend. All it needs to know is the total amount. I am not sure if your API endpoint currently return this total value, but in the code sample below I will assume it does and that it is called total (EDIT: your API does return this value, and it's called totalElements in the response).
    3. Then, whenever you click on the table pagination UI to go to a new page or to change the number of records per page, you will fire a new request to get only the elements for that new page, and then you have to "override" the previous rows in your component state with the ones received in the response. To accomplish this, we pass the current page and the rowsPerPage to the useEffect dependency array (the second parameter, which in your code is an empty array []). This means that whenever the value of page or rowsPerPage change, the code inside the effect will be run again.

    Here is more or less how it should look:

    export default function BusinessCustomersTable() {
      const classes = useStyles();
      const [order, setOrder] = React.useState<Order>("asc");
      const [orderBy, setOrderBy] = React.useState<keyof Data>("businessName");
      const [selected, setSelected] = React.useState<number[]>([]);
      const [page, setPage] = React.useState(0);
      const [rowsPerPage, setRowsPerPage] = React.useState(5);
      const [rows, setRows] = useState<Data[]>([]);
      const [loading, setLoading] = useState(false);
    
      // TODO - move this to API file
      const apiUrl = "http://185.185.126.15:8080/api/management/onboarding/task";
    
      // Your API returns a property called `totalElements`.
      // We need to set pass it to the 'count' prop of the TablePagination component to do server side pagination.
      // Ref.: https://mui.com/api/table-pagination/
      const [totalRows, setTotalRows] = useState(0);
     
      useEffect(() => {
        axios.get(apiUrl, {
            params: { page, size: rowsPerPage },
          })
          .then((response) => {
    
              setRows(response.data.content);
    
              // As I said above, your response should return what's the total amount of rows.
              // In your API response, that value is called `totalElements`.
              setTotalRows(response.data.totalElements);
    
              setLoading(false);
          })
          .catch((error) => {
            console.error(error);
            setLoading(false);
          });
    
    
      }, [page, rowsPerPage]); // Whenever the current 'page' or the amount of 'rowsPerPage' change, your request will be fired again.
    
      const handleRequestSort = (
          event: React.MouseEvent<unknown>,
          property: keyof Data
      ) => {
        const isAsc = orderBy === property && order === "asc";
        setOrder(isAsc ? "desc" : "asc");
        setOrderBy(property);
      };
    
      const handleSelectAllClick = (
          event: React.ChangeEvent<HTMLInputElement>
      ) => {
        if (event.target.checked) {
          const newSelecteds = rows.map((n) => n.id);
          setSelected(newSelecteds);
          return;
        }
        setSelected([]);
      };
    
      const handleClick = (event: React.MouseEvent<unknown>, id: number) => {
        const selectedIndex = selected.indexOf(id);
        let newSelected: number[] = [];
    
        if (selectedIndex === -1) {
          newSelected = newSelected.concat(selected, id);
        } else if (selectedIndex === 0) {
          newSelected = newSelected.concat(selected.slice(1));
        } else if (selectedIndex === selected.length - 1) {
          newSelected = newSelected.concat(selected.slice(0, -1));
        } else if (selectedIndex > 0) {
          newSelected = newSelected.concat(
              selected.slice(0, selectedIndex),
              selected.slice(selectedIndex + 1)
          );
        }
    
        setSelected(newSelected);
      };
    
      const handleChangePage = (event: unknown, newPage: number) => {
        setPage(newPage);
      };
    
      const handleChangeRowsPerPage = (
          event: React.ChangeEvent<HTMLInputElement>
      ) => {
        setRowsPerPage(parseInt(event.target.value, 10));
        setPage(0);
      };
    
      const handleDeleteClick = async () => {
        // npm install qs
        var qs = require("qs");
    
        const response = await axios.delete(apiUrl, {
          params: {
            ids: selected,
          },
          paramsSerializer: (params) => {
            return qs.stringify(params);
          },
        });
    
        if (response.status === 204) {
          const updatedData = rows.filter(
              (row) => !selected.includes(row.id)
          ); // It'll return all data except selected ones
    
          setRows(updatedData); // reset rows to display in table.
        }
      };
    
      const isSelected = (id: number) => selected.indexOf(id) !== -1;
    
      const emptyRows =
          rowsPerPage - Math.min(rowsPerPage, rows.length - page * rowsPerPage);
    
      return (
          <div className={classes.root}>
            <Paper className={classes.paper}>
              <EnhancedTableToolbar
                  numSelected={selected.length}
                  onClick={handleDeleteClick}
              />
              <TableContainer>
                <Table
                    className={classes.table}
                    aria-labelledby="tableTitle"
                    aria-label="enhanced table"
                >
                  <EnhancedTableHead
                      classes={classes}
                      numSelected={selected.length}
                      order={order}
                      orderBy={orderBy}
                      onSelectAllClick={handleSelectAllClick}
                      onRequestSort={handleRequestSort}
                      rowCount={rows.length}
                  />
                  <TableBody>
                    {loading ? (
                        <div className="spinerr">
                          <CircularProgress />
                        </div>
                    ) : null}
                    {stableSort(rows, getComparator(order, orderBy))
                        .map((row, index) => {
                          const isItemSelected = isSelected(row.id);
                          const labelId = `enhanced-table-checkbox-${index}`;
    
                          return (
                              <TableRow
                                  hover
                                  onClick={(event) =>
                                      handleClick(event, row.id)
                                  }
                                  role="checkbox"
                                  aria-checked={isItemSelected}
                                  tabIndex={-1}
                                  key={row.businessName}
                                  selected={isItemSelected}
                              >
                                <TableCell padding="checkbox">
                                  <Checkbox
                                      checked={isItemSelected}
                                      inputProps={{
                                        "aria-labelledby":
                                        labelId,
                                      }}
                                  />
                                </TableCell>
                                <TableCell
                                    component="th"
                                    id={labelId}
                                    scope="row"
                                    padding="none"
                                >
                                  {row.id}
                                </TableCell>
                                <TableCell align="right">
                                  {row.businessName}
                                </TableCell>
                                <TableCell align="right">
                                  {row.title}
                                </TableCell>
                                <TableCell align="right">
                                  {row.status}
                                </TableCell>
                              </TableRow>
                          );
                        })}
                    {emptyRows > 0 && (
                        <TableRow style={{ height: 53 * emptyRows }}>
                          <TableCell colSpan={6} />
                        </TableRow>
                    )}
                  </TableBody>
                </Table>
              </TableContainer>
              <TablePagination
                  rowsPerPageOptions={[5, 10, 25]}
                  component="div"
                  count={totalRows} // This is what your request should be returning in addition to the current page of rows.
                  rowsPerPage={rowsPerPage}
                  page={page}
                  onPageChange={handleChangePage}
                  onRowsPerPageChange={handleChangeRowsPerPage}
              />
            </Paper>
          </div>
      );
    }
    

    Further unrelated things to consider:

    • The current way the useEffect is written might give you some errors if you unmount the component before the request has finished (if you navigate somewhere else in the page for example). That's because it will be trying to set the state (setLoading and setRows) of an unmounted component. To guard against this scenario you could follow this other answer.
    • When you change the rowsPerPage it seems that you are also resetting the page to be the first one. This will cause the effect, and thus the request, be fired twice. You could instead either not reset the page to be the first one, or keep the state of the current page and the rowsPerPage inside one common useState hook, and update always both at once.