webup.org/blog

🌙

Sticky Table Header with React Hooks

December 01, 2020   | 10 min read

Sticky table header

Using a <table /> element instead of flexbox for data presentation is a good thing. How to turn the table header sticky with the help of React in that case? How to apply the solution into a production code? This blog post is all about that.

What follows is not a trivial tutorial on how you should solve the task. It is not a theory or fictional implementation, either. Instead, the focus is around a possible solution tested in real projects that you can easily reuse. It also sorts out some of the edge cases when working with <table />.

Go straight to the code, if that’s mostly what your are looking for.

Table of Contents:

  1. Tables vs Flexbox

  2. Sticky Header (code solution)

  3. Final Words
  4. Resources


Tables vs Flexbox

Whenever you have to deal with data presentation, often the first intention is to create a <div /> based layout with flexbox. People are somehow biased to tables. Tables have a bad reputation of being used for building web pages in the past. But if done right, they can save you a ton of problems. Tables do also play very well with React.

On the other side, it doesn’t come very handy to loop over your data and place it in a flexbox based grid. A good illustration on the problem is described in Accessible, Simple, Responsive Tables.

Flexbox based table layout

Table layout with flexbox. The screenshot is taken from here.

The table-like layout above is styled with flex and looks very similar to:

<div class="table">
  <h3 class="header">Eddard Stark</h3>  <div class="row">Has a sword named Ice</div>
  <div class="row">No direwolf</div>
  <div class="row">Lord of Winterfell</div>

  <h3 class="header">Jon Snow</h3>  <div class="row">Has a sword named Longclaw</div>
  <div class="row">Direwolf: Ghost</div>
  <div class="row">Knows nothing</div>

  ...
</div>

A question quickly arises: How easy would be to iterate over the headers and rows data with the given markup?

Contrary, some of the table benefits include:

  1. Column width control via header cells
  2. Painless component-wise split between header and content (table rows)
  3. Works out of the box (no css)

All these are closely related to the challenges behind turning table headers (<thead />) into sticky items. Understanding them, should help you better follow the code solution after.


You can build table layouts by using the usual <table /> tag or achieve the same via css with display: table and semantic elements (<div />).

// This
<table>
  <thead />  ...
</table>

// is the same as
<div style={{ display: "table" }}>
  <div style={{ display: "table-header-group" }} />  ...
</div>

Same visual result. The first one, though, will cause React to complain (also in tests) if you place <thead /> outside its <table /> parent.

<div>  <thead />
</div>
- Warning: validateDOMNesting(...): <thead> cannot appear as a child of <div>.

For the sake of simplicity and to be more explicit, all examples that come after are based on the <table /> element.

Back on the benefits.

Control Columns via Header Cells

It may appear counterintuitive since the header and body cells are placed far from each other in the DOM.

<table>
  <thead>
    <tr>
      <th style="width: 200px;">Header cell</th>    </tr>
  </thead>
  <tbody>
    <tr>
      <td>Table cell 1</td>    </tr>
    <tr>
      <td>Table cell 2</td>
    </tr>
  </tbody>
</table>

In this example the width of all <td /> elements will be the same as the width set with <th style="width: 200px;">Header cell</th>.

You get a side effect that allows for easy control over columns size with no additional cost of setting extensive css rules.

Header and Content Component Split

Apart from column sizing, sorting and filtering features are too mostly attached to the headers. It turns out they are very powerful unit for ruling the whole table. Such dependencies pop in especially whenever you need to split the table into React components.

Look into this Table component interface (without getting into details):

  <Table sortable headers={["Country", "Population"]} data={data}>
    {dataAfterSorting => (
      dataAfterSorting.map(country =>
        <TableDataRow data={country} />
      )
    )}
  </Table>

This structure comes natural because:

  1. It follows how tables render in the DOM (with separate header and body sections).
  2. Sorting functionality is attached to the header.

Headers set their own styles. That includes sorting indicators, hover states, but behavior (click handlers) too. A separate component that orchestrates the whole content being decoupled from it.

  1. The content is not aware of its context.

Components like <TableDataRow /> may live outside the table. It accepts a slice of a pre-sorted data and simply renders a row with its own styling. This component is not aware of its context and doesn’t need to. With one exception: the amount of cells (<td />) it displays must be the same as in the header.

Tables Work Out of the Box

Tables are straightforward and well known. You don’t need additional code to achieve a basic presentation for a given data set. By simply using the <table /> structure you already have a form for the numbers.

The same is not true for flexbox as discussed earlier.

Sticky Header (code solution)

Comparison between render props and custom hook form

This is the demo implementation and its code can be found in the CodeSandbox project. The stickiness is achieved by a simple <Table /> component and a useStickyHeader React hook.

Reuse it by adding your custom table styles in styles.css.

<Table /> Component Interface

The Table component itself is rendered like so

// App.js
const tableHeaders = ["Country", "Code", "Area", "Flag"];

export const tableData = [
  {
    country: "Brazil",
    code: "BR",
    area: "8,515,767 km2",
    flag: "🇧🇷"
  },
  ...
];

<Table headers={tableHeaders} data={tableData} />

See App.js

where its headers prop accepts an array of strings and data is an array of objects.

<Table />’s interface is not so crucial for the actual sticky implementation and you can build your own abstraction.

<Table /> Component Implementation

Below is the code behind Table.js. It serves as a wrapper for the table and its sticky header.

// Table.js
function Table({ headers = [], data = [] }) {
  const { tableRef, isSticky } = useStickyHeader();

  const renderHeader = () => (
    <thead>
      <tr>
        {headers.map(item => <th key={item}>{item}</th>)}
      </tr>
    </thead>
  );

  return (
    <div>
      {isSticky && (
        <table
          className="sticky"
          style={{
            position: "fixed",
            top: 0,
            left: 0,
            right: 0
          }}
        >
          {renderHeader()}
        </table>
      )}
      <table ref={tableRef}>
        {renderHeader()}
        <tbody>
          {data.map(item => (
            <tr key={item.code}>
              <td>{item.country}</td>
              <td>{item.code}</td>
              <td>{item.area}</td>
              <td>{item.flag}</td>
            </tr>
          ))}
        </tbody>
      </table>
    </div>
  );
}

View Table.js in CodeSandbox.

A few important aspects require a bit of details here.

const { tableRef, isSticky } = useStickyHeader();

The custom React hook with two props exposed:

  • tableRef - used to ref the table element you want to have a sticky header for.
  • isSticky - a flag turning true whenever the table is over the page’s top edge.
// Render if isSticky is true.
{isSticky && (
  // This is a required <table /> wrapper for the sticky header.
  // .sticky class distinguishes from the original table
  // and the additional style enables the stickiness.
  <table
    className="sticky"
    style={{
      position: "fixed",
      top: 0,
      left: 0,
      right: 0
    }}
  >
    {/* Render the same table header */}
    {renderHeader()}  </table>
)}

That part renders a sticky header if isSticky is true.

The sticky element above should inherit the original <table />’s styling in order to achieve the same appearance.

Another thing to note - there are two calls of renderHeader(). It means two <thead />s in the markup if stickiness is enabled. This is required. The original header needs to fill the physical space on top of the table. And it can’t go sticky since position: fixed takes elements out of their context. In this case introducing a second copy of the header is one way to address the issue.

useStickyHeader() Implementation

The useStickyHeader hook is probably the only piece of code you would need given the notes on the <Table /> component.

// useStickyHeader.js
const useStickyHeader = (defaultSticky = false) => {
  const [isSticky, setIsSticky] = useState(defaultSticky);
  const tableRef = useRef(null);

  const handleScroll = useCallback(({ top, bottom }) => {
    if (top <= 0 && bottom > 2 * 68) {
      !isSticky && setIsSticky(true);
    } else {
      isSticky && setIsSticky(false);
    }
  }, [isSticky]);

  useEffect(() => {
    const handleScroll = () => {
      handleScroll(tableRef.current.getBoundingClientRect());
    };
    window.addEventListener("scroll", handleScroll);

    return () => {
      window.removeEventListener("scroll", handleScroll);
    };
  }, [handleScroll]);

  return { tableRef, isSticky };
};

View useStickyHeader.js in CodeSandbox.

Quickly going through the code chunk by chunk.

const [isSticky, setIsSticky] = useState(defaultSticky);
const tableRef = useRef(null);

useState provides a way to update the isSticky flag based on some calculations. It takes a default value passed by the parent (the page may load with the table in the middle). tableRef is simply a ref to the table element required for some calculations later on.

const handleScroll = useCallback(({ top, bottom }) => {
  // The number 68 is hardcoded here and is the header's height.
  // It could also be skipped
  if (top <= 0 && bottom > 2 * 68) {    !isSticky && setIsSticky(true);
  } else {
    isSticky && setIsSticky(false);
  }
  // returns a new memoized callback
  // whenever the value of isSticky changes
}, [isSticky]);

And here follow the necessary calculations. { top, bottom } describes the table’s position on the screen. Once it starts passing off (top <= 0) or there is a visual space for at least two headers (bottom > 2 * 68) - the sticky mode is enabled.

Sticky table header

The second part of the hook’s implementation is its side effect. It does the scroll event binding and passes the current table dimensions down to the evaluation callback.

useEffect(() => {
  const handleScroll = () => {
    // Pass the current bounding rect of the table
    handleScroll(tableRef.current.getBoundingClientRect());  };
  window.addEventListener("scroll", handleScroll);
  // Clear the effect
  return () => {
    window.removeEventListener("scroll", handleScroll);
  };
  // Recreate the effect if handleScroll is a new function
}, [handleScroll]);

Final Words

The full solution lives here.

Turning a table header sticky could be challenging in contrast to something made out of flexbox. It is frustrating to see that simply applying position: fixed to the header doesn’t magically work. And perhaps having to render two <thead />s is too much.

On the other hand tables come very handy at presenting array-like data with many default benefits. That’s why a separate blog post was dedicated to the header challenge. The minimum you would need to untangle it is a custom React hook being the main bolt.

Resources


rss