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 repo

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

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.

React Folder Structure - Sticky Headers & Columns

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.

React Hydration Error

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!

Buy Me A Coffee