React & Material UI Table with Sticky Headers and Columns - 2022-12-12
Building a table component with sticky headers & columns using React and Material UI
Recently I was tasked with understanding some user requirements for an application being built to replace excel spreadsheets. The main table needed a sticky header and sticky columns so that the user can maintain important columns in view while sorting and viewing the data.
Here is a link to the live demo
After some back and fourth about the UI and style (Client is very difficult), we finally got to the actual table. Here is what I was able to iron out
Requirements
Header must be sticky
First three columns must be sticky
Some columns must be sortable
Must have pagination
Table must be compact, "to have as much content on screen as possible"
Select All / Select individual rows feature. (Exporting reasons which I won't get into this time)
Lets get started on the overall structure of the application. Heads up I will be using NextJS and Material UI.
Table of Contents
Table / index.js
Table / index Full JSX
TblContainer
TblStickyHeader
Table Body Section
TblPagination
Sticky Cell
Index.js (Display Table)
Conclusion
You can find the NextJs and Material UI template on their website.
After removing some of the template components, here is my complete folder structure.
pages & public
Standard NextJs files
src
components - Here is where we I keep all my customized components
Table - Customized table components
StickyCell.js - Customized cell that will handle the sticky functionality
useFetchData.js - custom hook to get our test data
user_mock_data.js - test data to populate our table
Copyright.js, createEmotionCache.js, Link.js - All came with NextJs
theme.js - Material UIs master style guide for the application
Now lets get started with the table components, since thats the main feature
Disclaimer 1: You should always write tests, I am still working to get better at that so for this demo I didn't include them.
Disclaimer 2: Material UI provides great examples regarding this topic, so I used their code to build on. I recommend you check it out. I will be combining logic and functionality from the Sorting & selectingsection with the stick header section.
Material UI - Sorting & Selecting
Material UI - Sticky Headers
Table / index.js
This file will be the main component that ties all the table components together.
function StickyTable({ data, tableHeaders }) {
const [order, setOrder] = useState("asc");
const [orderBy, setOrderBy] = useState("");
const [selected, setSelected] = useState([]);
const [page, setPage] = useState(0);
const [rowsPerPage, setRowsPerPage] = useState(10);
const emptyRows = page > 0 ?
Math.max(0, (1 + page) * rowsPerPage - data.length)
: 0;
const isSelected = (name) =>
selected.indexOf(name) !== -1;
The component will taken in two props. The data object array and the header object array.
State objects & Variables
order: the way the selected field will be ordered. Ascending / Descending order
orderBy: the selected field to order the table with
selected: array of selected rows.
page: used in the pagination component, stores the current page is being displayed on the table
rowsPerPage: used in the pagination component, stores how many rows will be displayed per page
emptyRows: Determines if there are empty rows in the data. Used for UI purposes
isSelected: boolean flag to track if a row has been selected
All these methods are provided by the Material UI documentation. Most are self explanatory but here is a quick description of what they do.
handleRequestSort: sets the order indicator as well as the field to be ordered by
handleSelectAllClick: sets all the rows in a selected state in the selected array
handleClick: sets the selected row in a selected state, and adds it to the selected array
handleChangePage: used in the pagination component, sets the new page of rows for the table
handleChangeRowsPerPage: used in the pagination component, sets the number of rows per page
descendingComparator: determines the order of the values being compared when a sort is performed.
getComparator: determines the toggle of the sort order for the field selected
stableSort: determines the rows in the selected sort
const handleRequestSort = (event, property) => {...}
const handleSelectAllClick = (event) => {...}
const handleClick = (event, name) => {...}
const handleChangePage = (event, newPage) => {...}
const handleChangeRowsPerPage = (event) => {...}
function descendingComparator(a, b, orderBy) => {...}
function getComparator(order, orderBy) => {...}
function stableSort(array, comparator) => {...}
The JSX below is the fully built component. We will go into each component after this.
return (
<>
<TblContainer size={"small"}>
<TblStickyHeader
headerCells={tableHeaders}
numSelected={selected.length}
onRequestSort={handleRequestSort}
onSelectAllClick={handleSelectAllClick}
order={order}
orderBy={orderBy}
rowCount={data.length}
/>
<TableBody>
{stableSort(data, getComparator(order, orderBy))
.slice(page * rowsPerPage, page * rowsPerPage + rowsPerPage)
.map((row, index) => {
const isItemSelected = isSelected(row.name);
return (
<TableRow
hover
onClick={(event) => handleClick(event, row.name)}
role="checkbox"
aria-checked={isItemSelected}
tabIndex={-1}
key={row.name}
selected={isItemSelected}
>
<StickyCell
zIndex={1001}
styles={{
borderRight: "solid",
borderRightWidth: "thin",
borderRightColor: "#d4d4d4",
}}
>
<TableCell padding="checkbox">
<Checkbox color="primary" checked={isItemSelected} />
</TableCell>
<TableCell>
<>{row.id}</>
</TableCell>
<TableCell sx={{ width: "250px" }}>{row.email}</TableCell>
<TableCell sx={{ width: "200px" }}>{row.name}</TableCell>
</StickyCell>
<TableCell>{row.country}</TableCell>
<TableCell>{row.ip_address}</TableCell>
<TableCell>{row.split ? "True" : "False"}</TableCell>
<TableCell>{row.currency}</TableCell>
<TableCell>{row.ein}</TableCell>
<TableCell>{row.md5}</TableCell>
<TableCell>
<Typography
variant="body2"
paragraph
sx={{ maxHeight: 100, overflow: "auto", width: 500 }}
>
{row.notes}
</Typography>
</TableCell>
</TableRow>
);
})}
{emptyRows > 0 && (
<TableRow
style={{
height: (dense ? 33 : 53) * emptyRows,
}}
>
<TableCell colSpan={6} />
</TableRow>
)}
</TableBody>
</TblContainer>
<TblPagination
recordsCount={data.length}
page={page}
rowsPerPage={rowsPerPage}
handleChangePage={handleChangePage}
handleChangeRowsPerPage={handleChangeRowsPerPage}
/>
</>
);
TblContainer
import { Table, TableContainer } from "@mui/material";
import React from "react";
function TblContainer({ children, size, maxHeight }) {
return (
<TableContainer sx={{ maxHeight: 650 }}>
<Table stickyHeader size={size ? size : "small"}>
{children}
</Table>
</TableContainer>
);
}
export default TblContainer;
The container component will take in the following props.
Children: Since we are building separate components of the table, we want to pass these as children elements.
Size: Which is a MUI string value for the formatting of the table. Default will be set to small
Max Height: Numeric value for the table height, will default to 650 (Just a personal preference)
TblStickyHeader
import {
Checkbox,
TableCell,
TableHead,
TableRow,
TableSortLabel,
} from "@mui/material";
import { useEffect, useState } from "react";
import StickyCell from "../../StickyCell";
export default function TblStickyHeader(props) {
const {
headerCells,
numSelected,
onRequestSort,
onSelectAllClick,
order,
orderBy,
rowCount,
} = props;
const [hasMounted, setHasMounted] = useState(false);
useEffect(() => {
setHasMounted(true);
}, []);
if (!hasMounted) {
return null;
}
const createSortHandler = (property) => (event) => {
onRequestSort(event, property);
};
return (
<>
<TableHead>
<TableRow>
<StickyCell>
<TableCell padding="checkbox">
<Checkbox
color="primary"
indeterminate={numSelected > 0 && numSelected < rowCount}
checked={rowCount > 0 && numSelected === rowCount}
onChange={onSelectAllClick}
/>
</TableCell>
{headerCells
.filter((header) => header.sticky === true)
.map((header, index) => (
<TableCell
key={header.id}
sortDirection={orderBy === header.id ? order : false}
sx={header.id !== "id" ? { width: "250px" } : { width: "0" }}
>
{header.disabledSorting ? (
header.label
) : (
<TableSortLabel
active={orderBy === header.id}
direction={orderBy === header.id ? order : "asc"}
onClick={createSortHandler(header.id)}
>
{header.label}
</TableSortLabel>
)}
</TableCell>
))}
</StickyCell>
{headerCells
.filter((header) => header.sticky === false)
.map((header) => (
<TableCell
key={header.id}
sortDirection={orderBy === header.id ? order : false}
>
{header.disabledSorting ? (
header.label
) : (
<TableSortLabel
active={orderBy === header.id}
direction={orderBy === header.id ? order : "asc"}
onClick={createSortHandler(header.id)}
>
{header.label}
</TableSortLabel>
)}
</TableCell>
))}
</TableRow>
</TableHead>
</>
);
}
The container component will take in the following props.
headerCells: The JSON object array containing all the table header information.
numSelected: the number of rows selected, used to determine the indeterminate state and if a row is checked.
onRequestSort: Function used to sort the selected column.
onSelectAllClick: Function to set all rows to a selected state.
order: Current order value for the selected column.
orderBy: Current field selected for ordering.
rowCount: The total number or rows in the table.
State Props
hasMounted: This is a bit of a "hack" for NextJS hydration. I was getting a hydration error when building this component and this is the only workaround I found that actually worked. Here is a link to NextJS documentation.
Methods
createSortHandler : Handler method that enables us to use the triggered event to call the Sorting method.
JSX (return statement)
Now we will use the Material UI documentation to build the entire header component. With the following customizations below
StickyCell: as we stated above, this is the customized cell that handles the columns which are set to sticky.
Filter & Mapping the headerCells prop: since we have a customized JSON object array, I set all the columns that need to be sticky to "sticky = true". We can filter on that to display the sticky columns first. Outside of the StickyCell tag, we then filter and map the non sticky columns.
disabledSorting check: again with the customized JSON object array, I set all the columns that can be sorted to disabledSorting = false. With this simple check we can simply display the column header, if we can sort on that column we then display the TableSortLabel MUI component.
Table Body Section
<TableBody>
{stableSort(data, getComparator(order, orderBy))
.slice(page * rowsPerPage, page * rowsPerPage + rowsPerPage)
.map((row, index) => {
const isItemSelected = isSelected(row.name);
return (
<TableRow
hover
onClick={(event) => handleClick(event, row.name)}
role="checkbox"
aria-checked={isItemSelected}
tabIndex={-1}
key={row.name}
selected={isItemSelected}
>
<StickyCell
zIndex={1001}
styles={{
borderRight: "solid",
borderRightWidth: "thin",
borderRightColor: "#d4d4d4",
}}
>
<TableCell padding="checkbox">
<Checkbox color="primary" checked={isItemSelected} />
</TableCell>
<TableCell>
<>{row.id}</>
</TableCell>
<TableCell sx={{ width: "250px" }}>{row.email}</TableCell>
<TableCell sx={{ width: "200px" }}>{row.name}</TableCell>
</StickyCell>
<TableCell>{row.country}</TableCell>
<TableCell>{row.ip_address}</TableCell>
<TableCell>{row.split ? "True" : "False"}</TableCell>
<TableCell>{row.currency}</TableCell>
<TableCell>{row.ein}</TableCell>
<TableCell>{row.md5}</TableCell>
<TableCell>
<Typography
variant="body2"
paragraph
sx={{ maxHeight: 100, overflow: "auto", width: 500 }}
>
{row.notes}
</Typography>
</TableCell>
</TableRow>
);
})}
{emptyRows > 0 && (
<TableRow
style={{
height: (dense ? 33 : 53) * emptyRows,
}}
>
<TableCell colSpan={6} />
</TableRow>
)}
</TableBody>
The majority of this section is code provided by Material UI, with one major change to include the StickyCell component to handle the sticky columns.
StickyCell: As we stated before, this is the customized cell that handles the columns which are set to sticky. This time we added additional styles to show some separation between the stick columns and the normal ones.
Notes: We should have taken this code and separated it into its own component, however I didn't feel the need because of the simplicity of my current need. Of course, this now makes the Table component more complex and longer, but I'm ok with that for now....
Styles: I made some customizations to the style of some cells in order to fit some specific specs for the client, they can probably be better but it works at the moment.
TblPagination
import { TablePagination } from "@mui/material";
import React from "react";
function TblPagination(props) {
const {
recordsCount,
page,
pages,
rowsPerPage,
handleChangePage,
handleChangeRowsPerPage,
} = props;
return (
<TablePagination
component="div"
count={recordsCount}
onPageChange={handleChangePage}
onRowsPerPageChange={handleChangeRowsPerPage}
page={page}
rowsPerPageOptions={pages}
rowsPerPage={rowsPerPage}
labelDisplayedRows={({ from, to, count }) =>
"Viewing {from > to ? to : from} - {to} of {count} | Showing {
rowsPerPage > count ? count : rowsPerPage
} Results"
}
/>
);
}
export default TblPagination;
Again, we are using the great code provided to us by Material UI. The only thing I did was extract it to its own component. In the case we do not want pagination on a page.
labelDisplayedRows: Material UI provides an easy way to customize the language on the label, which I took full advantage of to impress my client. Note: be sure to include $ in front of the , or else you will get errors.
StickyCell
import { TableCell } from "@mui/material";
import React from "react";
function StickyCell(props) {
const {
children,
styles,
backgroundColor = "#ffffff",
color = "#000000",
left = 0,
position = "sticky",
zIndex = 1002,
width = "150px",
} = props;
return (
<TableCell
style={{
...styles,
backgroundColor: backgroundColor,
color: color,
left: left,
position: position,
zIndex: zIndex,
width: width,
}}
>
{children}
</TableCell>
);
}
export default StickyCell;
This is where all the sticky column magic happens. The overall thought is that, we want a parent cell that will handle the stickiness functionality so that we don't have to individually worry about cell to cell.
Logic: basically we set the left, position and zIndex properties in a way that forces this component to always appear to the left and on top of the other columns when we scroll.
children: passing the children components to render inside this component.
styles: any additional styles we may need.
backgroundColor: the background color of the sticky cell, default is set to white.
color: the color of the text, default set to black.
left: the horizontal position of a positioned element, default set to 0. This will make sure the sticky columns stay furthest fixed.
position: defines the position of an element in a document, and works with the left and z-index property. Default set to "sticky", which makes the element position based on the users scroll position.
zIndex: defines the stack order of an element. Basically, an element with a higher number will appear in front of one with a lesser number. This will make our sticky columns always appear in front of the rest. Default set to 1002, seemed like a high enough number to me.
width: customized width property. I set it to 150px but that was for my specific need.
Now let get this table component displayed, in this case the index.js page.
import React from "react";
import Container from "@mui/material/Container";
import Box from "@mui/material/Box";
import Copyright from "../src/Copyright";
import StickyTable from "../src/components/Table/index.js";
import useFetchData from "../src/components/useFetchData";
const tableHeaders = [
{ id: "id", label: "ID", sticky: true, disabledSorting: false },
{ id: "email", label: "Email", sticky: true, disabledSorting: false },
{ id: "name", label: "Name", sticky: true, disabledSorting: false },
{ id: "country", label: "Country", sticky: false, disabledSorting: false },
{
id: "ip_address",
label: "IP Address",
sticky: false,
disabledSorting: true,
},
{ id: "split", label: "Split Status", sticky: false, disabledSorting: true },
{ id: "currency", label: "Currency", sticky: false, disabledSorting: false },
{ id: "ein", label: "EIN", sticky: false, disabledSorting: true },
{ id: "md5", label: "MD5 Hash", sticky: false, disabledSorting: false },
{ id: "notes", label: "Notes", sticky: false, disabledSorting: true },
];
export default function Index() {
const { isLoading, apiData, error } = useFetchData("");
return (
<>
{isLoading && <span>Loading.....</span>}
{!isLoading && error ? (
<span>Error in fetching data ...</span>
) : (
<>
<Container maxWidth="xl">
<Box sx={{ my: 4 }}>
<StickyTable data={apiData} tableHeaders={tableHeaders} />
<Copyright />
</Box>
</Container>
</>
)}
</>
);
}
useFetchData: Custom hook I used to fetch data, in this case it just get some test data from a file.
tableHeaders: JSON object array containing the header information used for the sticky functionality and disabling the sorting. This logic is taken from the Material UI documentation.
Styles: The StickyTable component is wrapped in a container to style the page.
Conclusion
I wanted to develop something that I could reuse in a different section of the application. We have one Table component that compiles all the various components together so that we can have a clean looking page that displays it.
We have a single parent component that handles the sticky functionality for the columns, so you don't have to worry about it at an individual level.
This isn't perfect and several areas need work / refactoring but for now it works.
You will get the following NextJS warnings, but I am happy with leaving them. I still haven't found a solution (that I like) that resolves this issue.
In the table header: Warning: validateDOMNesting(...): <th>cannot appear as a child of <th>.
In the table body: Warning: validateDOMNesting(...): <td> cannot appear as a child of <td>.
Happy Coding!
