27 min read

Building a Dynamic, Reusable Interactive Tanstack Table

Building a Dynamic, Reusable Interactive Tanstack Table

Recently, I worked on a project that required a robust, adaptable table setup across various sections within an application, each with unique data and layout requirements. The project demanded advanced functionalities—such as customizable columns, dynamic filtering, pagination, and role-based views—so I explored third-party libraries to streamline development while ensuring high performance. I’ve recreated that solution here, and this demo showcases the adaptable table I built to efficiently manage diverse data requirements, offering powerful data handling features and quick adaptability to meet a range of use cases within the application.

You can view the demo here.

Initial Requirements

  • Sorting: Users should be able to sort data by any column, including having default sorting set on specific tables.
  • Pagination: The table should handle pagination efficiently, even for large datasets.
  • Search: A global search input should be available to exclude particular columns from the search.
  • Filtering: Column-specific filtering should be available.
  • Role-Based Column Visibility: Certain columns should only be visible to specific user roles, like Admin or Company-level views.
  • Multiple Views: The table should adjust based on the view level (Admin, Company, or My View).
  • Column Reordering: The ability to reorder columns based on each table's configuration.
  • Calculations: The table should have the ability to display totals for specific pieces of the data set.
  • Mobile Friendly: Making sure the table maintains usability on mobile devices.

Researching Table Packages

I eventually settled on using TanStack Table after exploring libraries such as PrimeReact, DataTable, and AG-Grid. TanStack does require more initial setup, but it is a fully customizable headless solution. You can choose any of the libraries above to create what you need, but take a look at their documentation before making a decision.

Folder Structure Breakdown

We need a well-organized folder structure to build a scalable, dynamic, and reusable interactive table. The following structure (under src) allows for flexibility in extending features in future versions while ensuring clarity in managing data and table configurations. While the original project did not use TypeScript, I used it in this demo.

💡
Only some files are listed here. The view the demo or check out the Github Repo. Also, our project did not use TypeScript as the team initially built without it, but it is used in this demo.
src/
├── components/
│   ├── BaseTable/                # Core reusable base table setup
│   ├── InteractiveTable/         # Components for the interactive table, including shared logic and configurations
│   ├── TotalEmployeesTable/      # Employee-specific table components and 
│   ├── ViewMode/                 # Allows switching between user views (demo specific)
├── config/                       # Configuration files for global settings (e.g., colors, fonts, view levels)
├── icons/                        # SVGs and icons used across the application
├── types/                        # TypeScript types used throughout the table setup
├── App.tsx                       # Main entry point, renders tables based on version selection
└── tsconfig.json                 # TypeScript configuration

Key Folder Details

  • components/:
    • BaseTable/: Contains the foundational setup for the base table. This setup is shared across multiple tables, ensuring reusable core functionalities like sorting, pagination, and filtering.
    • InteractiveTable/: This folder houses the components specific to the interactive table setup. It includes shared logic and configurations used by different versions of the tables, such as the search input, view-level switcher, and table configurations.
    • TotalEmployeesTable/: This directory contains the employee-specific table components and versioned implementations. It holds the different iterations of the table, allowing for easy tracking of feature progression across versions.
    • ViewMode/: This folder handles specific code for the demo that allows you to switch between user views.
    • xyzTable/: Various other tables and versions we may build
  • config/: Centralized configuration files for the application, such as:
    • viewModes.ts: Manages views based on the user type as well as the table layout we want to see.
  • icons/: This folder contains SVG icons used in table headers, rows, and other UI elements throughout the application.
  • types/: Contains TypeScript types (reactTable.ts) used across the project, ensuring strong typing for the interactive table setup, column configurations, and data fetching.
  • App.tsx: The main file responsible for rendering different table versions. It dynamically loads table components based on the version selected, providing flexibility in testing and showcasing different configurations.

This folder structure keeps things organized while allowing for easy reusability for additional tables. I would generally separate hooks and sample data into their separate folders, but I've decided to keep files together based on features. For example, all hooks and data for a specific table are in that folder.

Rendering different versions of the tables

I've created a relatively straightforward way of viewing different table versions within this demo. You can see this in the App.tsx file. A couple of buttons allow you to switch between table versions.

// src/App.tsx

import { useState } from "react";
import BaseTable from "./components/BaseTable";
import TotalEmployeesTable from "./components/TotalEmployeesTable";

export default function App() {
  const [version, setVersion] = useState("v1");

  return (
    <div className="min-h-screen bg-gray-100 p-4 sm:p-6">
      <div className="flex flex-wrap gap-4 mb-4">
        <button
          onClick={() => setVersion("v1")}
          className="px-4 py-2 bg-blue-500 text-white rounded hover:bg-blue-600"
        >
          Version 1
        </button>
        <button
          onClick={() => setVersion("v2")}
          className="px-4 py-2 bg-green-500 text-white rounded hover:bg-green-600"
        >
          Version 2
        </button>
      </div>

      <div className="bg-white p-4 rounded shadow-md">
        {version === "v1" && <BaseTable />}
        {version === "v2" && <TotalEmployeesTable />}
      </div>
    </div>
  );
}

App.test.tsx.

Creating an initial TanStack Table

Before tackling the requirements list, I built an initial table to familiarize myself with the TanStack Table library.

Rendering the basic table requires a few things:

  1. Data
  2. A column configuration
  3. Rendering of the actual data within the table layout

We start by creating our BaseTable.test.tsx file to build our component. These tests ensure we correctly display the table headers, rows, and columns.

Initial dataset

We want a minimal dataset to render our table to see how things work. We start with an array of people with their firstName, lastName, age, visits, status, and progress.

type Person = {
  firstName: string;
  lastName: string;
  age: number;
  visits: number;
  status: string;
  progress: number;
};

const defaultData: Person[] = [
  {
    firstName: "tanner",
    lastName: "linsley",
    age: 24,
    visits: 100,
    status: "In Relationship",
    progress: 50,
  },
  {
    firstName: "tandy",
    lastName: "miller",
    age: 40,
    visits: 40,
    status: "Single",
    progress: 80,
  },
  {
    firstName: "joe",
    lastName: "dirte",
    age: 45,
    visits: 20,
    status: "Complicated",
    progress: 10,
  },
];

Column Configuration

Then, we use the createColumnHelper method to define our column configuration. This method helps us specify how each column should display and access specific pieces of data that we want to render in our layouts.

const columnHelper = createColumnHelper<Person>();

const columns = [
  columnHelper.accessor("firstName", {
    cell: (info) => info.getValue(),
    header: () => <span>First Name</span>,
    footer: (info) => info.column.id,
  }),
  columnHelper.accessor((row) => row.lastName, {
    id: "lastName",
    cell: (info) => <i>{info.getValue()}</i>,
    header: () => <span>Last Name</span>,
    footer: (info) => info.column.id,
  }),
  columnHelper.accessor("age", {
    header: () => "Age",
    cell: (info) => info.renderValue(),
    footer: (info) => info.column.id,
  }),
  columnHelper.accessor("visits", {
    header: () => <span>Visits</span>,
    footer: (info) => info.column.id,
  }),
  columnHelper.accessor("status", {
    header: "Status",
    footer: (info) => info.column.id,
  }),
  columnHelper.accessor("progress", {
    header: "Profile Progress",
    footer: (info) => info.column.id,
  }),
];

Rendering the data within the table

Next, we call the useReactTable hook with our data and column configuration to render our table.

export default function Table() {
  const rerender = useReducer(() => ({}), {})[1];

  const table = useReactTable({
    data: defaultData,
    columns,
    getCoreRowModel: getCoreRowModel(),
  });

  return (
    <div className="p-2">
      <h1>Table v1</h1>
      <div className="overflow-x-auto">
        <table className="min-w-full border">
          <thead>
            {table.getHeaderGroups().map((headerGroup) => (
              <tr key={headerGroup.id} className="bg-gray-100">
                {headerGroup.headers.map((header) => (
                  <th
                    key={header.id}
                    className="px-4 py-2 text-left text-sm font-semibold text-gray-600 whitespace-nowrap"
                  >
                    {header.isPlaceholder
                      ? null
                      : flexRender(
                          header.column.columnDef.header,
                          header.getContext()
                        )}
                  </th>
                ))}
              </tr>
            ))}
          </thead>
          <tbody>
            {table.getRowModel().rows.map((row) => (
              <tr key={row.id} className="border-b">
                {row.getVisibleCells().map((cell) => (
                  <td
                    key={cell.id}
                    className="px-4 py-2 text-sm text-gray-700 whitespace-nowrap"
                  >
                    {flexRender(cell.column.columnDef.cell, cell.getContext())}
                  </td>
                ))}
              </tr>
            ))}
          </tbody>
          <tfoot>
            {table.getFooterGroups().map((footerGroup) => (
              <tr key={footerGroup.id} className="bg-gray-100">
                {footerGroup.headers.map((header) => (
                  <th
                    key={header.id}
                    className="px-4 py-2 text-left text-sm font-semibold text-gray-600 whitespace-nowrap"
                  >
                    {header.isPlaceholder
                      ? null
                      : flexRender(
                          header.column.columnDef.footer,
                          header.getContext()
                        )}
                  </th>
                ))}
              </tr>
            ))}
          </tfoot>
        </table>
      </div>
      <div className="h-4" />
      <button
        onClick={() => rerender()}
        className="mt-4 w-full sm:w-auto border p-2 text-white bg-sky-500 hover:bg-sky-700 rounded"
      >
        Re-render
      </button>
    </div>
  );
}

We now have a very simple table displaying our data.

You can review the base implementation demo here.

The v1 demo is pretty straightforward, but the setup gets more complicated as you continue to add functionality.

Extending and Building a Reusable Interactive Table Base

Our next step is to start with our reusable table implementation nested under src/components/InteractiveTable.

TableHeader

We'll begin with src/components/InteractiveTable/TableHeader , which will have files for index.tsx, HeaderCell.tsx, TableHeader.test.tsx, and TableHeaderRow.tsx.

HeaderCell

Our HeaderCell component is our most deeply nested child component, so we'll start from there.

The HeaderCell component renders individual header cells in our table, with sorting functionality and flexible styling.

Here’s a breakdown of its main features and how it contributes to the table’s functionality:

Accessing and Applying Custom Metadata

// src/components/InteractiveTable/TableHeader/HeaderCell.tsx

import classNames from "classnames";
import { flexRender, Header } from "@tanstack/react-table";
import ArrowUpIcon from "../../../icons/ArrowUpIcon";
import ArrowDownIcon from "../../../icons/ArrowDownIcon";
import { ColumnMetaOptions, HeaderCellProps } from "../../../types/react-table";

const getCellMeta = <TData,>(header: Header<TData, unknown>) => {
  const meta: ColumnMetaOptions = header.column.columnDef.meta || {};
  return {
    flexJustify: meta?.flexJustify || "inherit",
    textAlign: meta?.textAlign || "inherit",
  };
};

The getCellMeta function extracts metadata (e.g., alignment and justification settings) from each header column definition. The metadata lets us customize the layout of each cell based on flexJustify and textAlign properties (or any others), allowing for flexible, per-column style adjustments.

💡
We have yet to show you the icons, but you can find those in the repo.

Rendering the Header Cell with Styling and Sorting

// src/components/InteractiveTable/TableHeader/HeaderCell.tsx

const HeaderCell = <TData,>({ header }: HeaderCellProps<TData>) => {
  const { flexJustify, textAlign } = getCellMeta(header);

  return (
    <th className="pr-6 last:pr-0" style={{ width: header.column.getSize() }}>
      <div
        className={classNames("align-items-center", {
          "cursor-pointer select-none": header.column.getCanSort(),
        })}
        style={{
          display: "flex",
          justifyContent: flexJustify,
          textAlign: textAlign,
          whiteSpace: "nowrap",
        }}
        onClick={header.column.getToggleSortingHandler()}
      >
        {header.column.getIsSorted() && (
          <span className="self-start mr-[7.5px]">
            {header.column.getIsSorted() === "asc" ? (
              <ArrowUpIcon />
            ) : (
              <ArrowDownIcon />
            )}
          </span>
        )}
        {flexRender(header.column.columnDef.header, header.getContext())}
      </div>
    </th>
  );
};

export default HeaderCell;

The main return block of HeaderCell combines several features:

  • Dynamic Styling: The cell’s styling is adjusted based on metadata properties. flexJustify and textAlign control layout alignment, while classNames applies classes for interactivity (like cursor-pointer for sortable columns).
  • Sorting Icons: If the column supports sorting, an ArrowUpIcon or ArrowDownIcon indicates the current sort direction (asc for ascending, desc for descending) based on the column's getIsSorted property.
  • Rendering Content: FlexRender renders the header content from the column definition, using header.getContext() to provide necessary data and functionality for each header cell.

Interactivity

Clicking on the header call calls the header column.getToggleSortingHandler() toggles the column's sorting state, alternating between ascending and descending order.

We load the HeaderCell component into our TableHeaderRow . The TableHeaderRow renders a single row of header cells for the table, using the provided headerGroup to map over each header and render individual HeaderCell components.

// src/components/InteractiveTable/TableHeader/TableHeaderRow.tsx

const TableHeaderRow = <TData,>({
  headerGroup,
}: TableHeaderRowProps<TData>) => (
  <tr className="border-b border-gray-300 w-full">
    {headerGroup.headers.map((header) => (
      <HeaderCell key={header.id} header={header} />
    ))}
  </tr>
);

Next, we render the TableHeaderRow in our TableHeader component.

// src/components/InteractiveTable/TableHeader/index.tsx

import TableHeaderRow from "./TableHeaderRow";
import { TableHeaderProps } from "../../../types/react-table";

const TableHeader = <TData,>({ headerGroups }: TableHeaderProps<TData>) => (
  <thead>
    {headerGroups.map((headerGroup) => (
      <TableHeaderRow key={headerGroup.id} headerGroup={headerGroup} />
    ))}
  </thead>
);

export default TableHeader;

Our TableHeader is finished now, along with our TableHeader.test.tsx.

Now that our TableHeader is complete, we move to our TableRow files.

TableRows

The TableRow component renders a single row in the InteractiveTable by iterating through each visible cell in the row. It dynamically uses the flexRender function to render cell content based on the column definition. Each cell’s meta property, accessed from ColumnMetaOptions, provides additional styling details like text alignment, while classNames helps manage conditional styling.

// src/components/InteractiveTable/TableRow/index.tsx

import { flexRender } from "@tanstack/react-table";
import classNames from "classnames";
import { ColumnMetaOptions, TableRowProps } from "../../../types/react-table";

const TableRow = <TData,>({ row }: TableRowProps<TData>) => {
  return (
    <tr className="border-b border-gray-300 w-full" key={row.id}>
      {row.getVisibleCells().map((cell) => {
        const meta = cell.column.columnDef.meta as ColumnMetaOptions;
        return (
          <td
            key={cell.id}
            className={classNames("whitespace-nowrap", {
              "pr-6": !cell.column.getIsLastColumn,
            })}
            style={{
              width: cell.column.getSize(),
              textAlign: meta?.textAlign || "inherit",
            }}
          >
            {flexRender(cell.column.columnDef.cell, cell.getContext())}
          </td>
        );
      })}
    </tr>
  );
};

export default TableRow;


TableRow.test.tsx.

Now that the header and rows are complete, we can move on to additional functionalities like pagination, filtering, and search.

TablePagination

The PaginationText component displays the current page out of the total page count, giving users a quick view of their position within paginated data.

// src/components/InteractiveTable/Pagination/PaginationText.tsx

type PaginationTextProps = {
  pageIndex: number;
  pageCount: number;
};

const PaginationText = ({ pageIndex, pageCount }: PaginationTextProps) => {
  return (
    <div className="text-gray-400 text-xs font-normal mb-0">
      Showing {pageCount === 0 ? 0 : pageIndex + 1} of {pageCount}
    </div>
  );
};

export default PaginationText;

The next piece of our pagination is our pagination buttons.

// src/components/InteractiveTable/Pagination/PaginationButtons.tsx

import CaretDoubleRightIcon from "../../../icons/CaretDoubleRightIcon";
import CaretDoubleLeftIcon from "../../../icons/CaretDoubleLeftIcon";
import CaretLeftIcon from "../../../icons/CaretLeftIcon";
import CaretRightIcon from "../../../icons/CaretRightIcon";

interface PaginationButtonProps {
  onFirstPageClick: () => void;
  onLastPageClick: () => void;
  onNextPageClick: () => void;
  onPreviousPageClick: () => void;
  onPageClick: (pageIndex: number) => void;
  getCanPreviousPage: boolean;
  getCanNextPage: boolean;
  pageCount: number;
  pageIndex: number;
}

export default function InteractiveTablePagination({
  onFirstPageClick,
  onLastPageClick,
  onNextPageClick,
  onPreviousPageClick,
  onPageClick,
  getCanPreviousPage,
  getCanNextPage,
  pageCount,
  pageIndex,
}: PaginationButtonProps) {
  const maxVisiblePages = 5;
  const currentGroup = Math.floor(pageIndex / maxVisiblePages);
  const startPage = currentGroup * maxVisiblePages + 1;
  const endPage = Math.min(startPage + maxVisiblePages - 1, pageCount);

  const renderPageNumbers = () => {
    const pages: JSX.Element[] = [];

    for (let index = startPage; index <= endPage; index++) {
      pages.push(
        <button
          key={index}
          onClick={() => onPageClick(index - 1)}
          className={`relative inline-flex items-center px-4 py-2 text-sm font-semibold ${
            pageIndex === index - 1
              ? "bg-indigo-600 text-white"
              : "text-gray-900 hover:bg-gray-50"
          } ring-1 ring-inset ring-gray-300`}
        >
          {index}
        </button>
      );
    }

    return pages;
  };

  const handleNextGroup = () => {
    const firstPageOfNextGroup = Math.min(endPage + 1, pageCount - 1);
    onPageClick(firstPageOfNextGroup - 1);
  };

  const handlePreviousGroup = () => {
    const lastPageOfCurrentGroup = Math.max(startPage - 1, 0);
    onPageClick(lastPageOfCurrentGroup - 1);
  };

  return (
    <div className="flex justify-center">
      <div className="flex flex-wrap items-center justify-between border-t border-gray-200 bg-white px-4 py-3 sm:px-6">
        {/* Mobile Pagination Controls */}
        <div className="flex flex-1 flex-wrap justify-center gap-2 sm:hidden">
          <button
            onClick={onFirstPageClick}
            disabled={!getCanPreviousPage}
            className={`relative inline-flex items-center rounded-md border border-gray-300 bg-white px-4 py-2 text-sm font-medium ${
              !getCanPreviousPage
                ? "cursor-not-allowed text-gray-300"
                : "text-blue-500 hover:bg-gray-50"
            }`}
          >
            <CaretDoubleLeftIcon
              color={!getCanPreviousPage ? "text-gray-300" : "text-blue-500"}
            />
          </button>
          <button
            onClick={onPreviousPageClick}
            disabled={!getCanPreviousPage}
            className={`relative inline-flex items-center rounded-md border border-gray-300 bg-white px-4 py-2 text-sm font-medium ${
              !getCanPreviousPage
                ? "cursor-not-allowed text-gray-300"
                : "text-blue-500 hover:bg-gray-50"
            }`}
          >
            <CaretLeftIcon
              color={!getCanPreviousPage ? "text-gray-300" : "text-blue-500"}
              label="Go To Previous Page"
            />
          </button>
          <button
            onClick={onNextPageClick}
            disabled={!getCanNextPage}
            className={`relative inline-flex items-center rounded-md border border-gray-300 bg-white px-4 py-2 text-sm font-medium ${
              !getCanNextPage
                ? "cursor-not-allowed text-gray-300"
                : "text-gray-900 hover:bg-gray-50"
            }`}
          >
            <CaretRightIcon
              color={!getCanNextPage ? "text-gray-300" : "text-gray-900"}
              label="Go To Next Page"
            />
          </button>
          <button
            onClick={onLastPageClick}
            disabled={!getCanNextPage}
            className={`relative inline-flex items-center rounded-md border border-gray-300 bg-white px-4 py-2 text-sm font-medium ${
              !getCanNextPage
                ? "cursor-not-allowed text-gray-300"
                : "text-gray-900 hover:bg-gray-50"
            }`}
          >
            <CaretDoubleRightIcon
              color={!getCanNextPage ? "text-gray-300" : "text-gray-900"}
            />
          </button>
        </div>

        {/* Desktop Pagination Controls */}
        <div className="hidden sm:flex sm:flex-1 sm:items-center sm:justify-between">
          <nav
            aria-label="Pagination"
            className="isolate inline-flex -space-x-px rounded-md shadow-sm"
          >
            <button
              onClick={onFirstPageClick}
              disabled={!getCanPreviousPage}
              aria-label="Go to First Page"
              className="relative inline-flex items-center rounded-l-md px-2 py-2 text-gray-400 ring-1 ring-inset ring-gray-300 hover:bg-gray-50 focus:z-20 focus:outline-offset-0"
            >
              <span className="sr-only">First</span>
              <CaretLeftIcon aria-hidden="true" />
            </button>
            <button
              onClick={handlePreviousGroup}
              disabled={startPage === 1}
              className="relative inline-flex items-center px-4 py-2 text-sm font-semibold text-gray-900 ring-1 ring-inset ring-gray-300 hover:bg-gray-50 focus:z-20 focus:outline-offset-0"
            >
              Previous
            </button>

            {renderPageNumbers()}

            <button
              onClick={handleNextGroup}
              disabled={endPage === pageCount}
              className="relative inline-flex items-center px-4 py-2 text-sm font-semibold text-gray-900 ring-1 ring-inset ring-gray-300 hover:bg-gray-50 focus:z-20 focus:outline-offset-0"
            >
              Next
            </button>
            <button
              onClick={onLastPageClick}
              disabled={!getCanNextPage}
              aria-label="Go to Last Page"
              className="relative inline-flex items-center rounded-r-md px-2 py-2 text-gray-400 ring-1 ring-inset ring-gray-300 hover:bg-gray-50 focus:z-20 focus:outline-offset-0"
            >
              <span className="sr-only">Last</span>
              <CaretRightIcon aria-hidden="true" />
            </button>
          </nav>
        </div>
      </div>
    </div>
  );
}

The pagination buttons in this component provide intuitive and accessible navigation through table pages, with controls optimized for mobile and desktop. For mobile users, a streamlined layout shows only essential navigation buttons—first, previous, next, and last—each dynamically styled to indicate availability. Desktop users get the added flexibility of direct page-number access with groups of five pages rendered at a time, enabling a more detailed exploration of table content.

Within the desktop view, the component organizes page numbers based on the user’s current range, allowing navigation through page groups with "Previous" and "Next" buttons and one-click access to the first or last page. We style each button to reflect the user’s current page position, with clear visual cues like icon color changes to indicate active or disabled states. This dual-view pagination system ensures that users on any device can efficiently move through data, making large tables accessible and manageable.

Pagination.test.tsx.

With our complete pagination, we can move on to our TableFilter component.

TableFilter

The TableFilter component efficiently filters table data based on selectable options in a dropdown. It receives selectedOption to display the current selection, selectOptions to populate the dropdown with choices, selectPlaceholder as a hint for users, and handleColumnFilterChange to manage the filter updates. When the user selects an option, the component triggers handleColumnFilterChange, which ensures that the table data re-renders according to the new filter criteria.

// src/components/InteractiveTable/Filter/index.tsx

import { Option } from "../../../types/react-table";

interface TableFilterProps {
  selectedOption: Option | null;
  selectOptions: Option[];
  selectPlaceholder: string;
  handleColumnFilterChange: (option: Option | null) => void;
}

const TableFilter: React.FC<TableFilterProps> = ({
  selectedOption,
  selectOptions,
  selectPlaceholder,
  handleColumnFilterChange,
}) => {
  return (
    <div className="mt-4 mb-4">
      <select
        className="w-full max-w-xs p-2 text-base text-gray-800 bg-gray-100 border border-gray-300 rounded-md transition duration-300 ease-in-out focus:border-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-500"
        value={selectedOption?.value || ""}
        onChange={(e) => {
          const selectedOption = selectOptions.find(
            (option) => option.value === e.target.value
          );
          handleColumnFilterChange(selectedOption || null);
        }}
      >
        <option value="">{selectPlaceholder}</option>
        {selectOptions.map((option) => (
          <option key={option.label} value={option.value}>
            {option.label}
          </option>
        ))}
      </select>
    </div>
  );
};

export default TableFilter;

Filters.tests.tsx test file.

Now that our filters component is complete let's move on to TableSearch.

TableSearch

We design the Search component to provide a smooth and user-friendly search experience using a debounced input. Delaying the input response limits unnecessary calls, especially when users type quickly. The DebouncedInput component within Search adjusts the input value only after the specified debounce time has passed, enhancing performance by reducing the load on search functions or APIs.

// src/components/InteractiveTable/Search/index.tsx

import { useState, useEffect } from "react";

interface DebouncedInputProps {
  className?: string;
  value: string;
  onChange: (value: string) => void;
  debounce?: number;
  placeholder?: string;
}

const DebouncedInput = ({
  value: initialValue,
  onChange,
  debounce,
  className = "",
  ...props
}: DebouncedInputProps) => {
  const [value, setValue] = useState(initialValue);

  useEffect(() => {
    setValue(initialValue);
  }, [initialValue]);

  useEffect(() => {
    const timeout = setTimeout(() => {
      onChange(value);
    }, debounce);

    return () => clearTimeout(timeout);
  }, [value, debounce, onChange]);

  return (
    <input
      {...props}
      value={value}
      onChange={(e) => setValue(e.target.value)}
      className={`w-full max-w-[232px] h-9 px-3 text-gray-800 bg-gray-100 border border-gray-300 rounded-md focus:outline-none focus:border-blue-500 focus:ring-2 focus:ring-blue-300 placeholder-gray-500 placeholder-italic ${className}`}
    />
  );
};

interface SearchInputProps {
  debounce: number;
  value: string;
  onChange: (value: string) => void;
}

const Search = ({ debounce = 500, value, onChange }: SearchInputProps) => {
  return (
    <DebouncedInput
      debounce={debounce}
      value={value ?? ""}
      onChange={onChange}
      placeholder="Search..."
      className="shadow-sm"
    />
  );
};

export default Search;

Search.test.tsx.

Now, we can combine all of these to generate our Views.

Views

Each View provides the main structure for displaying each of our tables. It displays sorting, filtering, and pagination, all of which can be enabled based on its props. Lastly, it renders our data to the user. It's essentially the container that ties all of the table components together.

First thing is that we need to create a types file to handle types for both our TableConfig and TableProps.

// src/types/react-table.ts

export type TableConfig<T extends object> = {
  columnResizeMode?: ColumnResizeMode;
  columns: ColumnDef<T>[];
  data: T[];
  debugTable?: boolean;
  defaultColumn?: {
    minSize: number;
    maxSize: number;
  };
  filterFns?: {
    fuzzy: (
      row: Row<T>,
      columnId: string,
      value: string,
      addMeta: (meta: Meta) => void
    ) => boolean;
  };
  handleGlobalFilterChange: React.Dispatch<React.SetStateAction<string>>;
  initialState: {
    sorting: { id: string; desc: boolean }[];
    columnVisibility?: {
      [key: string]: boolean;
    };
  };
  state: {
    columnFilters: ColumnFilter[];
    columnOrder: string[];
    columnVisibility?: {
      [key: string]: boolean;
    };
    globalFilter: string;
    pagination: {
      pageIndex: number;
      pageSize: number;
    };
  };
  sortDescFirst?: boolean;
};

export type TableProps<T extends object> = {
  count: number;
  countKey: keyof T;
  countTitle: string;
  customConfiguration?: TableConfig<T>;
  enableSearch: boolean;
  filteredRowCount?: number;
  footerInfo?: {
    link: string;
    text: string;
  } | null;
  globalFilter: string;
  handleColumnFilterChange?: (filterValue: Option | null) => void;
  handleGlobalFilterChange?: (filterValue: string) => void;
  icon: React.ReactNode;
  onColumnOrderChange?: React.Dispatch<React.SetStateAction<string[]>>;
  onColumnFiltersChange?: OnChangeFn<ColumnFiltersState>;
  onPaginationChange?: React.Dispatch<
    React.SetStateAction<{ pageIndex: number; pageSize: number }>
  >;
  selectConfig: {
    handleSelectChange?: (option: Option) => void;
    selectedOption: Option | null;
    selectOptions: Option[];
    selectPlaceholder: string;
    showSelect: boolean;
  };
  pageSize?: number;
  paginationConfig?: {
    pageCount: number;
    pageIndex: number;
  };
  table?: Table<T>;
  TableSearch: (props: TableSearchProps) => JSX.Element;
  title: string;
  tooltipText?: string;
  tableView: string;
  viewLevel: string;
};

We import those types and start with a TableView.

// src/components/InteractiveTable/Views/TableView.tsx

import TableHeader from "../TableHeader";
import TableRow from "../TableRow";
import PaginationText from "../Pagination/PaginationText";
import PaginationButtons from "../Pagination/PaginationButtons";
import Filter from "../Filter";
import { TableProps } from "../../../types/react-table";

const TableView = <TData extends object>({
  countTitle,
  filteredRowCount,
  footerInfo,
  handleColumnFilterChange = () => {},
  handleGlobalFilterChange = () => {},
  paginationConfig,
  selectConfig,
  table,
  TableSearch,
  title,
  enableSearch = true,
}: TableProps<TData>) => {
  if (!table) return null;

  const pageIndex = paginationConfig?.pageIndex;
  const pageCount = paginationConfig?.pageCount;

  return (
    <div className="flex flex-col">
      <h1 className="my-3">{title}</h1>
      {enableSearch && (
        <TableSearch
          value={table.getState().globalFilter || ""}
          onChange={handleGlobalFilterChange}
        />
      )}

      {selectConfig?.showSelect && (
        <Filter
          selectOptions={selectConfig?.selectOptions}
          selectedOption={selectConfig?.selectedOption}
          selectPlaceholder={selectConfig?.selectPlaceholder}
          handleColumnFilterChange={handleColumnFilterChange}
        />
      )}

      <div className="mt-6 overflow-x-auto">
        <table className="w-full">
          <TableHeader headerGroups={table.getHeaderGroups()} />
          <tbody>
            {table.getRowModel().rows.map((row) => (
              <TableRow key={row.id} row={row} />
            ))}
          </tbody>
        </table>
      </div>

      <div className="flex flex-wrap items-center justify-between gap-y-2 mt-4 mb-4 gap-x-4 text-gray-400 text-sm">
        <div>
          {filteredRowCount} {countTitle}
        </div>
        {footerInfo ? (
          <a href={footerInfo.link} className="text-blue-500">
            {footerInfo.text}
          </a>
        ) : null}

        {pageIndex !== undefined && pageCount !== undefined && (
          <>
            <PaginationText pageIndex={pageIndex} pageCount={pageCount} />
            <PaginationButtons
              pageIndex={pageIndex}
              pageCount={pageCount}
              onFirstPageClick={() => table.setPageIndex(0)}
              onLastPageClick={() => table.setPageIndex(pageCount - 1)}
              onNextPageClick={() => table.nextPage()}
              onPreviousPageClick={() => table.previousPage()}
              getCanPreviousPage={table.getCanPreviousPage()}
              getCanNextPage={table.getCanNextPage()}
              onPageClick={(page: number) => {
                table.setPageIndex(page);
              }}
            />
          </>
        )}
      </div>
    </div>
  );
};

export default TableView;

I'm also duplicating TableView to create a ListView component that will look different from TableView.

💡
The ListView is currently a duplicate of the TableView. You can adjust this ListView to display your table however you like. For example, in the original implementation, the ListView rendered 50 rows compared to 10 in the TableView. The ListView also had a different layout.

With our views complete, we can pull those into our InteractiveTable index file to render the actual tables.

Interactive Table index component

I'm showing you the entire file and will break it into chunks afterward.

// components/InteractiveTable/index.tsx

import {
  getCoreRowModel,
  getFilteredRowModel,
  getPaginationRowModel,
  getSortedRowModel,
  useReactTable,
  Row,
} from "@tanstack/react-table";
import { calculateCount } from "./tableHelpers";
import TableView from "./Views/TableView";
import { FuzzyFilterMeta, TableProps } from "../../types/react-table";

const fuzzyFilter = <TData,>(
  row: Row<TData>,
  columnId: string,
  value: string,
  addMeta: (meta: FuzzyFilterMeta) => void
) => {
  const rowValue = row.getValue(columnId);

  if (typeof rowValue === "string") {
    const itemRank = rowValue.toLowerCase().includes(value.toLowerCase());
    addMeta({ itemRank });
    return itemRank;
  }

  return false;
};

const baseTableConfiguration = {
  columns: [],
  data: [],
  debugTable: false, // Change to true when testing
  defaultColumn: {
    minSize: 50,
    maxSize: 400,
  },
  getCoreRowModel: getCoreRowModel(),
  getFilteredRowModel: getFilteredRowModel(),
  getPaginationRowModel: getPaginationRowModel(),
  getSortedRowModel: getSortedRowModel(),
  globalFilterFn: fuzzyFilter,
  sortDescFirst: true,
};

const Table = <T extends object>({
  countKey,
  countTitle,
  enableSearch,
  footerInfo,
  globalFilter,
  handleColumnFilterChange = () => {},
  handleGlobalFilterChange = () => {},
  icon,
  selectConfig,
  paginationConfig,
  customConfiguration,
  TableSearch,
  title,
  tableView = "table",
  viewLevel,
}: TableProps<T>) => {
  const mergedTableConfig = {
    ...baseTableConfiguration,
    ...customConfiguration,
  };

  const table = useReactTable({
    ...mergedTableConfig,
  });

  const pageIndex = table.getState().pagination.pageIndex;
  const pageCount = table.getPageCount();
  const filteredRowCount =
    calculateCount({
      rows: table.getFilteredRowModel().rows,
      key: countKey,
    }) || 0;

  return (
    <TableView
      count={filteredRowCount}
      countKey={countKey}
      countTitle={countTitle}
      enableSearch={enableSearch}
      filteredRowCount={filteredRowCount}
      footerInfo={footerInfo}
      globalFilter={globalFilter}
      handleColumnFilterChange={handleColumnFilterChange}
      handleGlobalFilterChange={handleGlobalFilterChange}
      onColumnOrderChange={() => {}}
      onPaginationChange={() => {}}
      icon={icon}
      paginationConfig={
        paginationConfig
          ? { ...paginationConfig, pageIndex, pageCount }
          : undefined
      }
      selectConfig={selectConfig}
      table={table}
      TableSearch={TableSearch}
      title={title}
      tableView={tableView}
      viewLevel={viewLevel}
    />
  );
};

export default Table;

We started by defining a base table configuration that applied to all tables:

const baseTableConfiguration = {
  columns: [],
  data: [],
  debugTable: false, // Change to true when testing
  defaultColumn: {
    minSize: 50,
    maxSize: 400,
  },
  getCoreRowModel: getCoreRowModel(),
  getFilteredRowModel: getFilteredRowModel(),
  getPaginationRowModel: getPaginationRowModel(),
  getSortedRowModel: getSortedRowModel(),
  globalFilterFn: fuzzyFilter,
  sortDescFirst: true,
};
  • columns: The column property dictates the configuration of our table columns, which will eventually come from a custom hook.
  • data: The data property is initialized as an empty array but will ultimately come from data fetched from our API (our demo uses a data sample file). Each table version will supply its dataset.
  • debugTable: This property allows us to see the debug information in the console while working with the table.
  • defaultColumn: We are setting minimum and maximum sizes for the columns within the table.
  • getCoreRowModel: The getCoreRowModel is a required property to render the table
  • getFilteredRowModel: This property enables the rendering of the rows when the table is filtered. We have to pass this property for client-side filtering.
  • getPaginationRowModel: We used this property for efficient pagination, ensuring the table performs well even with large datasets.
  • getSortedRowModel: This property is similar to the getFilteredRowModel in that we add it for client-side sorting.
  • globalFilterFn: This property takes in a custom filter function, allowing us to filter the table however we prefer.
  • sortDescFirst: This sets the default sort order.

Extending the base configuration and initializing the table

We allow extending the base configuration for each custom table by merging our base configuration with a custom configuration prop that gets passed through. We then pass that merged configuration object into our useReactTable hook.

const table = useReactTable({
  ...mergedTableConfig,
});

Additional Configuration (pageIndex, pageCount, filteredRowCount)

We then perform some calculations on the table, which allows us to pass some of these through as props to our View components.

  const pageIndex = table.getState().pagination.pageIndex;
  const pageCount = table.getPageCount();
  const filteredRowCount =
    calculateCount({
      rows: table.getFilteredRowModel().rows,
      key: countKey,
    }) || 0;

Render the TableView Component

We currently only render a table view of our table, but this is easily extendible by creating a separate view and conditionally rendering that table here.

<TableView
  count={filteredRowCount}
  countKey={countKey}
  countTitle={countTitle}
  enableSearch={enableSearch}
  filteredRowCount={filteredRowCount}
  footerInfo={footerInfo}
  globalFilter={globalFilter}
  handleColumnFilterChange={handleColumnFilterChange}
  handleGlobalFilterChange={handleGlobalFilterChange}
  onColumnOrderChange={() => {}}
  onPaginationChange={() => {}}
  icon={icon}
  paginationConfig={
    paginationConfig
      ? { ...paginationConfig, pageIndex, pageCount }
      : undefined
  }
  selectConfig={selectConfig}
  table={table}
  TableSearch={TableSearch}
  title={title}
  tableView={tableView}
  viewLevel={viewLevel}
/>

Table.test.tsx.

Our reusable interactive table base is now complete. We can move on to building our custom table configurations.


ViewMode (for the demo)

The ViewMode component offers a simple way to toggle between different user views, such as "My View," "Admin View," and "Company View." Our original implementation used props to render the different views.

// src/components/ViewMode/index.tsx

import { userView } from "../../config/viewModes";

interface ViewModeProps {
  currentViewLevel: string;
  onViewLevelChange: (viewType: string) => void;
}

const ViewMode = ({
  currentViewLevel = userView.ME,
  onViewLevelChange,
}: ViewModeProps) => {
  return (
    <div className="flex space-x-4">
      <button
        className={`px-4 py-2 border ${
          currentViewLevel === userView.ME
            ? "bg-blue-600 text-white"
            : "bg-white border-gray-300"
        }`}
        onClick={() => onViewLevelChange(userView.ME)}
      >
        My View
      </button>
      <button
        className={`px-4 py-2 border ${
          currentViewLevel === userView.ADMIN
            ? "bg-blue-600 text-white"
            : "bg-white border-gray-300"
        }`}
        onClick={() => onViewLevelChange(userView.ADMIN)}
      >
        Admin View
      </button>
      <button
        className={`px-4 py-2 border ${
          currentViewLevel === userView.COMPANY
            ? "bg-blue-600 text-white"
            : "bg-white border-gray-300"
        }`}
        onClick={() => onViewLevelChange(userView.COMPANY)}
      >
        Company View
      </button>
    </div>
  );
};

export default ViewMode;

ViewMode.test.tsx.

We now have everything we need to build out our custom implementation of our TotalEmployeesTable.

Building our TotalEmployeesTable

Now, we can build our custom table implementation for the TotalEmployeesTable under its own folder.

Our index file renders our table based on the userView.

//  src/components/TotalEmployeesTable/index.tsx

import { useState } from "react";
import ViewMode from "../ViewMode";
import TotalEmployeesTable from "./TotalEmployeesTable";
import { dataView, userView } from "../../config/viewModes";

export default function Table() {
  const [currentViewLevel, setCurrentViewLevel] = useState(userView.ME);

  const handleViewLevelChange = (viewLevel: string) => {
    setCurrentViewLevel(viewLevel);
  };

  return (
    <div className="container mx-auto">
      <ViewMode
        currentViewLevel={currentViewLevel}
        onViewLevelChange={handleViewLevelChange}
      />

      <TotalEmployeesTable
        dataView={dataView.TABLE}
        userView={currentViewLevel}
      />
    </div>
  );
}

index.test.tsx.

Next, we create our TotalEmployeesTable.tsx file, which contains most of our table configuration.

If you remember from our very basic table, our table needs data, a column configuration, and a rendering of the actual data.

We've built the rendering portion of the actual data with our table views. We've also managed a base configuration within our InteractiveTable/index file. Now, we're creating our unique configuration for this specific table.

// src/components/TotalEmployeesTable/TotalEmployeesTable.tsx

import { useState, useEffect, useMemo } from "react";
import InteractiveTable from "../InteractiveTable";
import Search from "../InteractiveTable/Search";
import useTotalEmployeesColumnConfig from "./useTotalEmployeesColumnConfig";
import useTotalEmployees from "./useTotalEmployees";
import {
  dataView as dataViewMode,
  userView as userViewMode,
} from "../../config/viewModes";
import {
  Option,
  EmployeeData,
  TableSearchProps,
} from "../../types/react-table";
import { ColumnFilter } from "@tanstack/react-table";
import { renderTitle } from "../InteractiveTable/tableHelpers";

type TotalEmployeesTableProps = {
  dataView: string;
  userView: string;
};

const TotalEmployeesTable = ({
  dataView = dataViewMode.TABLE,
  userView = userViewMode.ME,
}: TotalEmployeesTableProps) => {
  // Data Fetching
  const { data, count, options } = useTotalEmployees();

  // Table Config
  const countKey = "hoursWorkedThisMonth";
  const { columns } = useTotalEmployeesColumnConfig(data as EmployeeData[]);
  const selectPlaceholder = "Select a department";

  const [columnOrder, setColumnOrder] = useState<string[]>([
    "employeeName",
    "employeeId",
    "department",
    "jobTitle",
    "performanceRating",
    "projectsCompleted",
    "hoursWorkedThisMonth",
    "lastPromotionDate",
  ]);

  const [globalFilter, setGlobalFilter] = useState<string>("");
  const [columnFilters, setColumnFilters] = useState<ColumnFilter[]>([]);

  const [selectedOption, setSelectedOption] = useState<Option | null>(null);
  const handleSelectChange = (option: Option | null) => {
    setGlobalFilter("");

    if (!option) {
      setSelectedOption(null);
      setColumnFilters([{ id: "department", value: "" }]);
      return;
    }

    setSelectedOption(option);
    setColumnFilters([{ id: "department", value: option.label }]);
  };

  const [pagination, setPagination] = useState({
    pageIndex: 0,
    pageSize: dataView === dataViewMode.LIST ? 50 : 10,
    pageCount: 0,
  });

  const baseColumnVisibility = {
    employeeName: true,
    employeeId: false,
    department: true,
    jobTitle: true,
    hoursWorkedThisMonth: true,
    projectsCompleted: false,
    performanceRating: false,
    lastPromotionDate: false,
  };

  const companyColumnVisibility = {
    projectsCompleted: true,
    performanceRating: true,
    lastPromotionDate: true,
  };

  const adminColumnVisibility = {
    employeeId: true,
  };

  const mergedColumnVisibility = useMemo(() => {
    return {
      ...baseColumnVisibility,
      ...(userView === userViewMode.COMPANY && companyColumnVisibility),
      ...(userView === userViewMode.ADMIN && adminColumnVisibility),
    };
  }, [userView]);

  const [columnVisibility, setColumnVisibility] = useState(
    mergedColumnVisibility
  );

  useEffect(() => {
    setColumnVisibility(mergedColumnVisibility);
  }, [mergedColumnVisibility]);

  const customConfiguration = {
    columns: columns || [],
    data: data || [],
    initialState: {
      sorting: [
        {
          id: "hoursWorkedThisMonth",
          desc: true,
        },
      ],
    },
    onColumnOrderChange: setColumnOrder,
    onColumnFiltersChange: setSelectedOption,
    handleGlobalFilterChange: setGlobalFilter,
    onPaginationChange: setPagination,
    onColumnVisibilityChange: setColumnVisibility,
    state: {
      columnFilters: columnFilters || [],
      columnOrder,
      columnVisibility: columnVisibility,
      globalFilter,
      pagination,
    },
  };

  const MemoizedTableSearch = useMemo(
    () => (props: TableSearchProps) => <Search {...props} debounce={300} />,
    []
  );

  const selectConfig = {
    handleSelectChange: handleSelectChange,
    selectedOption: selectedOption,
    selectOptions: options,
    selectPlaceholder: selectPlaceholder,
    showSelect: true,
  };

  const tooltipText = "Total number of employees";

  if (!data || !columns) return null;

  return (
    <InteractiveTable
      count={count}
      countKey={countKey}
      countTitle="Total Hours Worked"
      customConfiguration={customConfiguration}
      enableSearch
      footerInfo={null}
      globalFilter={globalFilter}
      handleColumnFilterChange={handleSelectChange}
      handleGlobalFilterChange={setGlobalFilter}
      icon={null}
      paginationConfig={pagination}
      selectConfig={selectConfig}
      TableSearch={MemoizedTableSearch}
      tableView="table"
      title={renderTitle(userView)}
      tooltipText={tooltipText}
      viewLevel={userView}
    />
  );
};

export default TotalEmployeesTable;

Building the custom employees table

The TotalEmployeesTable component is the centerpiece of this implementation. It fetches data from an internal or external API source and configures it into a format suitable for display within a table. Here’s what the TotalEmployeesTable does:

  1. Data Fetching: Dynamically fetches employee data based on different view levels (e.g., ME, COMPANY, or ADMIN).
  2. Configurable Columns: Dynamically adjusts column visibility and order based on the selected view level.
  3. Global and Column-Specific Filtering: Allows data to be filtered based on global and column-specific criteria.
  4. Pagination: Enables paginated views of the data.
  5. Customization: Allows customization of the table’s configuration (e.g., sorting, filtering, pagination).

We'll now break down each implementation part to understand how it comes together.

Data Fetching with useTotalEmployees

The useTotalEmployees hook handles fetching employee data and provides the data the table will display along with the options for our dropdown filter (optional).

// src/components/TotalEmployeesTable/useTotalEmployees.ts

import { useEffect, useState } from "react";
import { employeeData } from "./data";
// import { userView as userViewMode } from "../../config/viewModes";

// const useTotalEmployees = ({ viewLevel = userViewMode.ME }) => {
const useTotalEmployees = () => {
  const [totalEmployeesCount, setTotalEmployeesCount] = useState(0);
  const [departmentOptions, setDepartmentOptions] = useState<
    { label: string; value: string }[]
  >([]);

  // let query = "";

  // switch (viewLevel) {
  //   case userViewMode.ME:
  //     query = "MY_QUERY";
  //     break;
  //   case userViewMode.COMPANY:
  //     query = "COMPANY_QUERY";
  //     break;
  //   case userViewMode.ADMIN:
  //     query = "ADMIN_QUERY";
  //     break;
  //   default:
  //     query = "MY_QUERY";
  // }

  useEffect(() => {
    if (employeeData.length === 0) return;

    const departmentMap: Record<string, number> = {};
    employeeData.forEach((employee) => {
      departmentMap[employee.department] = employee.departmentId;
    });

    const uniqueCompanies = Object.entries(departmentMap)
      .map(([department, departmentId]) => ({
        label: department,
        value: String(departmentId),
      }))
      .sort((a, b) => a.label.localeCompare(b.label));

    setDepartmentOptions(uniqueCompanies);

    const uniqueEmployeeIds = new Set(
      employeeData.map((item) => item.employeeId)
    );
    setTotalEmployeesCount(uniqueEmployeeIds.size);
  }, []);

  // Replace with actual API query in the future

  return {
    data: employeeData,
    count: totalEmployeesCount,
    options: departmentOptions,
  };
};

export default useTotalEmployees;

Explanation:

  • The hook first initializes totalEmployeesCount and departmentOptions as state variables.
  • On useEffect, the hook generates a department map from the employeeData and creates a unique list of departments. This list is used to populate the select dropdown for filtering by department.
  • The total number of unique employees is calculated by checking unique employeeId values and stored in totalEmployeesCount.
  • The hook returns the employee data, the total employee count, and the department filter options.

This hook currently returns static data (employeeData) but can easily be swapped out to fetch actual data from an API.

Configuring Table Columns with useTotalEmployeesColumnConfig

One of the most powerful features of our table is its dynamic column configuration. The useTotalEmployeesColumnConfig hook defines the structure of the table columns, including sorting, rendering, and filtering behavior.

// src/components/TotalEmployeesTable/useTotalEmployeesColumnConfig.tsx

import { useMemo } from "react";
import { ColumnDef } from "@tanstack/react-table";
import { EmployeeData } from "../../types/react-table";

const useTotalEmployeesColumnConfig = (data: EmployeeData[]) => {
  const totalHoursWorked = useMemo(() => {
    if (!data) return 0;
    return data.reduce((total, curr) => total + curr.hoursWorkedThisMonth, 0);
  }, [data]);

  const columns: ColumnDef<EmployeeData>[] = useMemo(
    () => [
      {
        accessorKey: "employeeId",
        displayName: "Employee ID",
        minSize: 220,
        maxSize: 250,
        header: () => <span>Employee ID</span>,
        cell: (info) => <span>{info.getValue() as string}</span>,
        footer: () => "Employee ID",
      },
      {
        accessorKey: "employeeName",
        displayName: "Employee",
        minSize: 220,
        maxSize: 250,
        header: () => <span>Employee</span>,
        cell: (info) => (
          <span>
            <a href={`/employees/profile/${info.row.original.employeeName}`}>
              {info.getValue() as string}
            </a>
          </span>
        ),
        footer: () => "Employee",
      },
      {
        accessorKey: "department",
        displayName: "Department",
        maxSize: 350,
        header: () => <span>Department</span>,
        cell: (info) => <span>{info.getValue() as string}</span>,
        footer: () => "Department",
      },
      {
        accessorKey: "hoursWorkedThisMonth",
        displayName: "Hours Worked",
        meta: {
          textAlign: "right",
          flexJustify: "end",
        },
        maxSize: 140,
        header: () => (
          <span>
            <div className="flex flex-col items-end">
              <span className="text-left">Hours Worked This Month</span>
            </div>
          </span>
        ),
        cell: (info) => <span>{info.getValue() as number}</span>,
        footer: () => "Total Views",
      },
      {
        accessorKey: "jobTitle",
        displayName: "Job Title",
        maxSize: 350,
        header: () => <span>Job Title</span>,
        cell: (info) => <span>{info.getValue() as string}</span>,
        footer: () => "Job Title",
      },
      {
        accessorKey: "lastPromotionDate",
        displayName: "Last Promotion Date",
        maxSize: 350,
        meta: {
          textAlign: "right",
          flexJustify: "end",
        },
        header: () => <span>Last Promotion Date</span>,
        cell: (info) => <span>{info.getValue() as string}</span>,
        footer: () => "Last Promotion Date",
      },
      {
        accessorKey: "performanceRating",
        displayName: "Performance Rating",
        maxSize: 350,
        header: () => <span>Performance Rating</span>,
        cell: (info) => <span>{info.getValue() as string}</span>,
        footer: () => "Performance Rating",
      },
      {
        accessorKey: "projectsCompleted",
        displayName: "Projects Completed",
        maxSize: 350,
        header: () => <span>Projects Completed</span>,
        cell: (info) => <span>{info.getValue() as string}</span>,
        footer: () => "Projects Completed",
      },
    ],
    [totalHoursWorked]
  );

  return { columns };
};

export default useTotalEmployeesColumnConfig;

Explanation:

  • useMemo: This memoizes the totalHoursWorked, recalculating only when the data changes.
  • Column Definitions: We define a set of column configurations for the table. Each column specifies:
    • accessorKey: The key used to access the data.
    • header: The column header can include additional styling.
    • cell: A custom render function to control how each cell is displayed.
  • These configurations are dynamically generated based on the EmployeeData structure and passed to the InteractiveTable.

Continuing the TotalEmployeesTable - Column Order

The columnOrder state allows us to rearrange the order of the columns coming from our columnConfiguration however we like.

// src/components/TotalEmployeesTable/TotalEmployeesTable.tsx

const [columnOrder, setColumnOrder] = useState<string[]>([
  "employeeName",
  "employeeId",
  "department",
  "jobTitle",
  "performanceRating",
  "projectsCompleted",
  "hoursWorkedThisMonth",
  "lastPromotionDate",
]);

Pagination

The pagination hook sets our default pagination values.

  const [pagination, setPagination] = useState({
    pageIndex: 0,
    pageSize: dataView.LIST ? 50 : 10,
    pageCount: 0,
  });

Dynamic column visibility based on the user's view level

One of the core features of this table is the ability to change which columns are visible depending on the viewMode—whether the user is viewing as ME, COMPANY, or ADMIN.

const baseColumnVisibility = {
  employeeName: true,
  employeeId: false,
  department: true,
  jobTitle: true,
  hoursWorkedThisMonth: true,
  projectsCompleted: false,
  performanceRating: false,
  lastPromotionDate: false,
};

const companyColumnVisibility = {
  projectsCompleted: true,
  performanceRating: true,
  lastPromotionDate: true,
};

const adminColumnVisibility = {
  employeeId: true,
};

const mergedColumnVisibility = useMemo(() => {
  return {
    ...baseColumnVisibility,
    ...(userView === userViewMode.COMPANY && companyColumnVisibility),
    ...(userView === userViewMode.ADMIN && adminColumnVisibility),
  };
}, [userView]);

const [columnVisibility, setColumnVisibility] = useState(
  mergedColumnVisibility
);

useEffect(() => {
  setColumnVisibility(mergedColumnVisibility);
}, [mergedColumnVisibility]);
  • Base Column Visibility: By default, specific columns (e.g., employeeName, department) are visible to all users.
  • Company and Admin Visibility: Additional columns are displayed if the user is viewing as COMPANY or ADMIN.
  • Memoization: The useMemo hook only recalculates the column visibility when the viewLevel changes.

Custom configuration

The customConfiguration object will be merged with our base configuration, allowing complete customization for this specific table.

const customConfiguration = {
  columns: columns || [],
  data: data || [],
  initialState: {
    sorting: [
      {
        id: "hoursWorkedThisMonth",
        desc: true,
      },
    ],
  },
  onColumnOrderChange: setColumnOrder,
  onColumnFiltersChange: setSelectedOption,
  handleGlobalFilterChange: setGlobalFilter,
  onPaginationChange: setPagination,
  onColumnVisibilityChange: setColumnVisibility,
  state: {
    columnFilters: columnFilters || [],
    columnOrder,
    columnVisibility: columnVisibility,
    globalFilter,
    pagination,
  },
};

MemoizedTableSearch

We memoize the search component to prevent it from re-rendering and losing focus on the input.

const MemoizedTableSearch = useMemo(
  () => (props: TableSearchProps) => <Search {...props} debounce={300} />,
  []
);

Select configuration

We pass a selectConfig object to our InteractiveTable, which contains all the configuration required to add a dropdown filter to our table.

Render the table

Lastly, we render the table by passing through our configuration and props.

  return (
    <InteractiveTable
      count={count}
      countKey={countKey}
      countTitle="Total Hours Worked"
      customConfiguration={customConfiguration}
      enableSearch
      footerInfo={null}
      globalFilter={globalFilter}
      handleColumnFilterChange={handleSelectChange}
      handleGlobalFilterChange={setGlobalFilter}
      icon={null}
      paginationConfig={pagination}
      selectConfig={selectConfig}
      TableSearch={MemoizedTableSearch}
      tableView="table"
      title={renderTitle(userView)}
      tooltipText={tooltipText}
      viewLevel={userView}
    />
  );

Final Table

Our final table looks like the following.

You can also test out the demo here.

This table component integrates a range of dynamic features, enabling efficient and flexible data visualization. Key functionalities include advanced pagination controls, fuzzy search-based filtering, and customizable column visibility based on user roles or views—supporting use cases like "Admin View," "Company View," and "My View." Users can easily interact with an intuitive search and filter interface, adjust page sizes, and navigate through data seamlessly. Its modular design, with customizable headers, rows, and filter configurations, ensures adaptability to various data structures.

Conclusion

Creating a dynamic, interactive table like this requires thoughtful planning, particularly when balancing reusability with complex configurations. This implementation delivers a flexible table that adapts to different user roles, offering a tailored experience based on view levels. The table’s design is also extendable, accommodating future enhancements like server-side pagination or real-time data updates with minimal adjustments.