React Table

Introduction

  • It is a headless UI library providing the logic, state, processing and API for UI elements and interactions, but do not provide markup, styles, or pre-built implementations.

  • The logic includes header grouping, column filtering (searching), column sorting, column visibility, expanding, resizing, pagination

  • Table instance is come from of data (from api), column definition, row models

Column Defintion

  • Column definition is mainly 3 types:

  • Accessor Columns (For displaying data)

  • Accessor columns have an underlying data model which means they can be sorted, filtered, grouped, etc. Display Columns (For displaying customized UI)

  • Display columns do not have a data model which means they cannot be sorted, filtered, etc, but they can be used to display arbitrary content in the table, eg. a row actions button, checkbox, expander, etc. Grouping Columns (For grouping columns into single)

  • Group columns do not have a data model so they too cannot be sorted, filtered, etc, and are used to group other columns together. It's common to define a header or footer for a column group.

const columnHelper = createColumnHelper<Person>()

// Make some columns!
const defaultColumns = [
  // Display Column
  columnHelper.display({
    id: 'actions',
    cell: props => <RowActions row={props.row} />,
  }),
  // Grouping Column
  columnHelper.group({
    header: 'Name',
    footer: props => props.column.id,
    columns: [
      // Accessor Column
      columnHelper.accessor('firstName', {
        cell: info => info.getValue(),
        footer: props => props.column.id,
      }),
      // Accessor Column
      columnHelper.accessor(row => row.lastName, {
        id: 'lastName',
        cell: info => info.getValue(),
        header: () => <span>Last Name</span>,
        footer: props => props.column.id,
      }),
    ],
  }),
  // Grouping Column
  columnHelper.group({
    header: 'Info',
    footer: props => props.column.id,
    columns: [
      // Accessor Column
      columnHelper.accessor('age', {
        header: () => 'Age',
        footer: props => props.column.id,
      }),
      // Grouping Column
      columnHelper.group({
        header: 'More Info',
        columns: [
          // Accessor Column
          columnHelper.accessor('visits', {
            header: () => <span>Visits</span>,
            footer: props => props.column.id,
          }),
          // Accessor Column
          columnHelper.accessor('status', {
            header: 'Status',
            footer: props => props.column.id,
          }),
          // Accessor Column
          columnHelper.accessor('progress', {
            header: 'Profile Progress',
            footer: props => props.column.id,
          }),
        ],
      }),
    ],
  }),
]

Row Model

  • Row models run under the hood of TanStack Table to transform your original data in useful ways that are needed for data grid features like filtering, sorting, grouping, expanding, and pagination.

import {
  getCoreRowModel,
  getExpandedRowModel,
  getFacetedMinMaxValues,
  getFacetedRowModel,
  getFacetedUniqueValues,
  getFilteredRowModel,
  getGroupedRowModel,
  getPaginationRowModel,
  getSortedRowModel,
}
//...
const table = useReactTable({
  columns,
  data,
  getCoreRowModel: getCoreRowModel(),
  getExpandedRowModel: getExpandedRowModel(),
  getFacetedMinMaxValues: getFacetedMinMaxValues(),
  getFacetedRowModel: getFacetedRowModel(),
  getFacetedUniqueValues: getFacetedUniqueValues(),
  getFilteredRowModel: getFilteredRowModel(),
  getGroupedRowModel: getGroupedRowModel(),
  getPaginationRowModel: getPaginationRowModel(),
  getSortedRowModel: getSortedRowModel(),
})

Table State

  • To get the functional state of table

  console.log("Expand", table.getState().expanded);
  // {<row index>: <isExpanded>}
  // Expand {16: true}
  console.log("Column Filter", table.getState().columnFilters);
  // [{id: <column_name>, value:<filter_value}]
  // Column Filter [{id: 'title', value: 'peter'}]
  console.log("Pagination", table.getState().pagination);
  // {pageIndex: <pageNum - 1>, pageSize: <pageSize>}
  // Pagination {pageIndex: 2, pageSize: 10}
  console.log("Sorting", table.getState().sorting);
  // [{id: <column_name>, desc: <isDescOrder>}]
  // Sorting [{id: 'id', desc: false}]
  console.log("Selection", table.getState().rowSelection);
  // {<row index>: <isSelected>}
  // Selection {16: true}

Implementation

  • Implementation, it is separated into data fetching, table definition, User interface

Data Fetching

useMyTableQuery.ts
import { useQuery } from "@tanstack/react-query";
import {
  ColumnFiltersState,
  PaginationState,
  SortingState,
} from "@tanstack/react-table";
import axios from "axios";
import { Product, productSchema } from "../../types/my-table";
interface Props {
  pagination: PaginationState;
  sorting: SortingState;
  filter: ColumnFiltersState;
}

export default function useMyTableQuery({
  pagination,
  sorting,
  filter,
}: Props) {
  return useQuery({
    queryKey: ["products", pagination],
    queryFn: async () => {
      const { data: products } = await axios.get<Product[]>(
        `https://fakestoreapi.com/products?pageIndex=${pagination.pageIndex}&pageSize=${pagination.pageSize}&sort=${sorting[0].id}:${sorting[0].desc ? "desc" : "asc"}&filter=${filter[0].id}:${filter[0].value}`,
      );
      return productSchema.array().parse(products);
    },
  });
}
my-table-ts
import { z } from "zod";

export const productSchema = z.object({
  id: z.number(),
  title: z.string(),
  price: z.number(),
  category: z.string(),
});

export type Product = z.infer<typeof productSchema>;

Table Definition

useMyTable.ts
import {
  ColumnDef,
  ColumnFiltersState,
  getCoreRowModel,
  getExpandedRowModel,
  getFilteredRowModel,
  getSortedRowModel,
  PaginationState,
  SortingState,
  useReactTable,
} from "@tanstack/react-table";
import useMyTableQuery from "./useMyTableQuery";
import { Product } from "../..//types/my-table";
const useMyTable = (columns: ColumnDef<Product, any>[]) => {
  const [pagination, setPagination] = React.useState<PaginationState>({
    pageIndex: 0,
    pageSize: 10,
  });
  const [sorting, setSorting] = React.useState<SortingState>([
    {
      id: "id",
      desc: false,
    },
  ]);
  const [filter, setFilter] = React.useState<ColumnFiltersState>([
    {
      id: "title",
      value: "",
    },
  ]);
  const { data: product } = useMyTableQuery({
    pagination,
    sorting,
    filter,
  });

  const table = useReactTable({
    defaultColumn: {
      size: 100, //starting column size
      minSize: 50, //enforced during column resizing
      maxSize: 300, //enforced during column resizing
    },
    columnResizeMode: "onChange",
    columnResizeDirection: "ltr",
    // inject data
    data: product ?? [],
    // inject column defintions
    columns,
    // inject row model
    getCoreRowModel: getCoreRowModel(),
    // insert react state into table status
    state: { pagination, sorting, columnFilters: filter },
    onPaginationChange: setPagination,
    onSortingChange: setSorting,
    onColumnFiltersChange: setFilter,
    pageCount: 10,
    manualPagination: true, // server-side
    // inject functional row model for client side handling
    // getPaginationRowModel: getPaginationRowModel() // client-side
    getSortedRowModel: getSortedRowModel(),
    // manualSorting: true,
    getFilteredRowModel: getFilteredRowModel(),
    // manualFiltering:true,
    getRowCanExpand: () => true, // Add your logic to determine 
    // if a row can be expanded. True means all rows include expanded data
    getExpandedRowModel: getExpandedRowModel(),
  });
  return { table };
};

export default useMyTable;

Context Provider

tableProvider.ts
import { Table } from "@tanstack/react-table";
import { createContext } from "react";

const TableContext = createContext<{ table: Table<any> | null }>({
  table: null,
});

export default TableContext;
useTableContext.ts
import { useContext } from "react";
import TableContext from "../../contexts/tableProvider";
import { Table } from "@tanstack/react-table";

const useTableContext = <T>() => {
  const table = useContext<{ table: Table<T> | null }>(TableContext);
  return table;
};

export default useTableContext;

User Interface

  • Container layer (Parent component)

my-table.tsx
import { createFileRoute } from "@tanstack/react-router";
import MyTable from "../../components/myTable";

export const Route = createFileRoute("/_authenticated/my-table")({
  component: () => <MyTablePage />,
});

const MyTablePage = () => {
  return (
    <MyTable>
      <MyTable.Search />
      <MyTable.Table />
      <MyTable.Pagination />
    </MyTable>
  );
};
index.tsx
import { ColumnDef, createColumnHelper } from "@tanstack/react-table";
import React from "react";
import { Product } from "../../types/my-table";
import Checkbox from "./checkBox";
import Row from "../layout/row";
import CustomTable from "./customTable";
import CustomSearch from "./customSearch";
import CustomPagination from "./customPagintaion";
import useMyTable from "../../hooks/my-table/useMyTable";
import TableContext from "../../contexts/tableProvider";

interface Props {
  children: React.ReactNode;
}
const MyTable = ({ children }: Props) => {
  const columnsHelper = createColumnHelper<Product>();
  const columns: ColumnDef<Product, any>[] = [
    columnsHelper.accessor("id", {
      header: ({ header, table }) => (
        <Row>
          <Checkbox
            {...{
              checked: table.getIsAllRowsSelected(),
              indeterminate: table.getIsSomeRowsSelected(),
              onChange: table.getToggleAllRowsSelectedHandler(),
            }}
          />
          <span>ID</span>
          <button
            onClick={() => {
              header.column.toggleSorting(
                header.column.getIsSorted() !== "desc",
              );
            }}
          >
            {{
              asc: "🔼",
              desc: "🔽",
            }[header.column.getIsSorted() as string] ?? null}
          </button>
        </Row>
      ),
      cell: ({ row, getValue }) => (
        <Row>
          <Checkbox
            {...{
              checked: row.getIsSelected(),
              indeterminate: row.getIsSomeSelected(),
              onChange: row.getToggleSelectedHandler(),
            }}
          />
          <span>{getValue()}</span>
        </Row>
      ),
      sortingFn: "alphanumeric", // use built-in sorting function by name
      sortUndefined: "last", //force undefined values to the end
    }),
    columnsHelper.accessor("title", { header: "Title" }),
    columnsHelper.accessor("price", { header: "Price" }),
    columnsHelper.accessor("category", { header: "Category" }),
    columnsHelper.display({
      id: "actions",
      cell: ({ row }) => (
        <button
          onClick={() => {
            row.getToggleExpandedHandler()();
          }}
          className="font-medium text-blue-600 dark:text-blue-500 hover:underline"
        >
          {row.getIsExpanded() ? "Collapse" : "Expand"}
        </button>
      ),
      header: "Action",
    }),
  ];
  const { table } = useMyTable(columns);

  return (
    <TableContext.Provider value={{ table }}>
      <div className="relative overflow-x-auto shadow-md sm:rounded-lg py-10 px-5">
        {children}
      </div>
    </TableContext.Provider>
  );
};
MyTable.Search = CustomSearch;
MyTable.Table = CustomTable;
MyTable.Pagination = CustomPagination;
export default MyTable;
  • Presentation Layer (Child Component)

CustomSearch.tsx
import React from "react";
import useTableContext from "../../hooks/my-table/useTableContext";
import { Product } from "../../types/my-table";

const CustomSearch = () => {
  const { table } = useTableContext<Product>();
  if (!table) return null;
  return (
    <div className="pb-4 bg-white dark:bg-gray-900">
      <label htmlFor="table-search" className="sr-only">
        Search
      </label>
      <div className="relative mt-1">
        <div className="absolute inset-y-0 rtl:inset-r-0 start-0 flex items-center ps-3 pointer-events-none">
          <svg
            className="w-4 h-4 text-gray-500 dark:text-gray-400"
            aria-hidden="true"
            xmlns="http://www.w3.org/2000/svg"
            fill="none"
            viewBox="0 0 20 20"
          >
            <path
              stroke="currentColor"
              strokeLinecap="round"
              strokeLinejoin="round"
              strokeWidth="2"
              d="m19 19-4-4m0-7A7 7 0 1 1 1 8a7 7 0 0 1 14 0Z"
            />
          </svg>
        </div>
        <input
          type="text"
          id="table-search"
          className="block pt-2 ps-10 text-sm text-gray-900 border border-gray-300 rounded-lg w-80 bg-gray-50 focus:ring-blue-500 focus:border-blue-500 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
          placeholder="Search for items"
          onChange={(e) => {
            table.getColumn("title")?.setFilterValue(e.target.value);
          }}
        />
      </div>
    </div>
  );
};

export default CustomSearch;
CustomTable.tsx
import { flexRender } from "@tanstack/react-table";
import React from "react";
import { Product } from "../../types/my-table";
import useTableContext from "../../hooks/my-table/useTableContext";

const CustomTable = () => {
  const { table } = useTableContext<Product>();
  if (!table) return null;
  console.log("Expand", table.getState().expanded);
  console.log("Column Filter", table.getState().columnFilters);
  console.log("Pagination", table.getState().pagination);
  console.log("Sorting", table.getState().sorting);
  console.log("Selection", table.getState().rowSelection);
  console.log(table.getRowModel().rows);
  return (
    <div style={{ direction: table.options.columnResizeDirection }}>
      <table
        {...{
          style: {
            width: table.getCenterTotalSize(),
          },
        }}
        className="text-sm text-left rtl:text-right text-gray-500 dark:text-gray-400  border-solid border-2 w-fit"
      >
        <thead className="text-xs text-gray-700 uppercase bg-gray-50 dark:bg-gray-700 dark:text-gray-400">
          {table.getHeaderGroups().map((headerGroup) => (
            <tr key={headerGroup.id}>
              {headerGroup.headers.map((header) => (
                <th
                  key={header.id}
                  scope="col"
                  className={`p-4 relative`}
                  style={{ width: `${header.getSize()}px` }}
                  colSpan={header.colSpan}
                >
                  {header.isPlaceholder
                    ? null
                    : flexRender(
                        header.column.columnDef.header,
                        header.getContext(),
                      )}
                  <div
                    className={`border-4 resizer ${
                      table.options.columnResizeDirection
                    } ${header.column.getIsResizing() ? "isResizing" : ""}`}
                    {...{
                      onDoubleClick: () => header.column.resetSize(),
                      onMouseDown: header.getResizeHandler(),
                      onTouchStart: header.getResizeHandler(),
                    }}
                  />
                </th>
              ))}
            </tr>
          ))}
        </thead>
        <tbody>
          {table.getRowModel().rows.map((row) => (
            <React.Fragment key={row.id}>
              <tr className="bg-white border-b dark:bg-gray-800 dark:border-gray-700 hover:bg-gray-50 dark:hover:bg-gray-600">
                {row.getVisibleCells().map((cell) => (
                  <td
                    className={`p-4 `}
                    style={{ width: `${cell.column.getSize()}px` }}
                    key={cell.id}
                  >
                    {flexRender(cell.column.columnDef.cell, cell.getContext())}
                  </td>
                ))}
              </tr>
              {row.getIsExpanded() && (
                <tr>
                  <td colSpan={row.getAllCells().length}>
                    {row.getValue("title")}
                  </td>
                </tr>
              )}
            </React.Fragment>
          ))}
        </tbody>
      </table>
    </div>
  );
};

export default CustomTable;
CustomPagination.tsx
import React from "react";
import useTableContext from "../../hooks/my-table/useTableContext";
import { Product } from "../../types/my-table";

const CustomPagination = () => {
  const { table } = useTableContext<Product>();
  if (!table) return null;
  const getPagination = (totalPages: number, currentPage: number): number[] => {
    const pageNumbers = [];
    const maxVisiblePages = 5;
    const halfVisible = Math.floor(maxVisiblePages / 2);

    let startPage = Math.max(1, currentPage - halfVisible);
    let endPage = Math.min(totalPages, currentPage + halfVisible);

    if (endPage - startPage < maxVisiblePages - 1) {
      if (startPage === 1) {
        endPage = Math.min(totalPages, startPage + maxVisiblePages - 1);
      } else if (endPage === totalPages) {
        startPage = Math.max(1, endPage - maxVisiblePages + 1);
      }
    }

    for (let i = startPage; i <= endPage; i++) {
      pageNumbers.push(i);
    }

    return pageNumbers;
  };
  const currentPage = table.getState().pagination.pageIndex + 1;
  return (
    <nav
      className="flex items-center flex-column flex-wrap md:flex-row justify-between pt-4"
      aria-label="Table navigation"
    >
      <span className="text-sm font-normal text-gray-500 dark:text-gray-400 mb-4 md:mb-0 block w-full md:inline md:w-auto">
        Page of{" "}
        <span className="font-semibold text-gray-900 dark:text-white">
          {table.getState().pagination.pageIndex + 1}
        </span>{" "}
        of{" "}
        <span className="font-semibold text-gray-900 dark:text-white">
          {table.getPageCount()}
        </span>
      </span>
      <ul className="inline-flex -space-x-px rtl:space-x-reverse text-sm h-8">
        <li>
          <button
            className="flex items-center justify-center px-3 h-8 ms-0 leading-tight text-gray-500 bg-white border border-gray-300 rounded-s-lg hover:bg-gray-100 hover:text-gray-700 dark:bg-gray-800 dark:border-gray-700 dark:text-gray-400 dark:hover:bg-gray-700 dark:hover:text-white"
            onClick={() => {
              table.setPageIndex(0);
            }}
          >
            {"<<"}
          </button>
        </li>
        <li>
          <button
            disabled={!table.getCanPreviousPage()}
            onClick={() => {
              table.previousPage();
            }}
            className="flex items-center justify-center px-3 h-8 leading-tight text-gray-500 bg-white border border-gray-300 hover:bg-gray-100 hover:text-gray-700 dark:bg-gray-800 dark:border-gray-700 dark:text-gray-400 dark:hover:bg-gray-700 dark:hover:text-white"
          >
            {"<"}
          </button>
        </li>
        {getPagination(table.getPageCount(), currentPage).map((pageNum) => (
          <li key={pageNum}>
            <button
              className={`${currentPage === pageNum ? "  font-bold" : ""} flex items-center justify-center px-3 h-8 leading-tight text-gray-500 bg-white border border-gray-300 hover:bg-gray-100 hover:text-gray-700 dark:bg-gray-800 dark:border-gray-700 dark:text-gray-400 dark:hover:bg-gray-700 dark:hover:text-white`}
              onClick={() => {
                table.setPageIndex(pageNum - 1);
              }}
            >
              {pageNum}
            </button>
          </li>
        ))}
        <li>
          <button
            className="flex items-center justify-center px-3 h-8 leading-tight text-gray-500 bg-white border border-gray-300 hover:bg-gray-100 hover:text-gray-700 dark:bg-gray-800 dark:border-gray-700 dark:text-gray-400 dark:hover:bg-gray-700 dark:hover:text-white"
            disabled={!table.getCanNextPage()}
            onClick={() => {
              table.nextPage();
            }}
          >
            {">"}
          </button>
        </li>
        <li>
          <button
            disabled={!table.getCanNextPage()}
            className="flex items-center justify-center px-3 h-8 leading-tight text-gray-500 bg-white border border-gray-300 rounded-e-lg hover:bg-gray-100 hover:text-gray-700 dark:bg-gray-800 dark:border-gray-700 dark:text-gray-400 dark:hover:bg-gray-700 dark:hover:text-white"
            onClick={() => {
              table.setPageIndex(table.getPageCount() - 1);
            }}
          >
            {">>"}
          </button>
        </li>
      </ul>
      <select
        className="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
        value={table.getState().pagination.pageSize}
        onChange={(e) => {
          console.log(e.target.value);
          table.setPageSize(Number(e.target.value));
        }}
      >
        {[10, 20, 30, 40, 50].map((pageSize) => (
          <option key={pageSize} value={pageSize}>
            Show {pageSize}
          </option>
        ))}
      </select>
    </nav>
  );
};

export default CustomPagination;
Checkbox.tsx
import React, { HTMLProps } from "react";

function Checkbox({
  indeterminate,
  className = "",
  ...rest
}: { indeterminate?: boolean } & HTMLProps<HTMLInputElement>) {
  const ref = React.useRef<HTMLInputElement>(null!);

  React.useEffect(() => {
    if (typeof indeterminate === "boolean") {
      ref.current.indeterminate = !rest.checked && indeterminate;
    }
  }, [ref, indeterminate, rest.checked]);

  return (
    <input
      type="checkbox"
      ref={ref}
      className={
        className +
        "w-4 h-4 text-blue-600 bg-gray-100 border-gray-300 rounded focus:ring-blue-500 dark:focus:ring-blue-600 dark:ring-offset-gray-800 dark:focus:ring-offset-gray-800 focus:ring-2 dark:bg-gray-700 dark:border-gray-600  cursor-pointer"
      }
      {...rest}
    />
  );
}

export default Checkbox;

Last updated

Was this helpful?