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.
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>
);
}
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:
- Data
- A column configuration
- 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.
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
andtextAlign
control layout alignment, whileclassNames
applies classes for interactivity (like cursor-pointer for sortable columns). - Sorting Icons: If the column supports sorting, an
ArrowUpIcon
orArrowDownIcon
indicates the current sort direction (asc for ascending, desc for descending) based on the column'sgetIsSorted
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;
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.
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;
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;
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
.
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}
/>
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;
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>
);
}
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:
- Data Fetching: Dynamically fetches employee data based on different view levels (e.g., ME, COMPANY, or ADMIN).
- Configurable Columns: Dynamically adjusts column visibility and order based on the selected view level.
- Global and Column-Specific Filtering: Allows data to be filtered based on global and column-specific criteria.
- Pagination: Enables paginated views of the data.
- 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
anddepartmentOptions
as state variables. - On
useEffect
, the hook generates a department map from theemployeeData
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 intotalEmployeesCount
. - 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 thetotalHoursWorked
, 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.