So You Want to Create Excel in the Browser?

Creating performant and extendable tables in React using react-virtualized

Posted by Sean Kemmis on June 29, 2017

Just Use <table>, Right?

As developers at Monsanto, we’ve all probably been tasked with recreating a tabular (excel-style) view in our web apps for users, and a majority of us have probably used the <table> tag — or something like bulma or bootstrap columns — to get the job done, and usually, that’s enough.

But what about when we need to render hundreds (or thousands!) of cells? What if we want to lock columns, or headers? What if we need to do all three? Well, table and the flex-boxed <div> tags leave a lot to be desired in all of those use cases.

In order to fix headers or columns, you either have to render them separately and do a lot of positioning magic on your own, or go through a lot of css trickery. This only gets worse when the table is so large it has to scroll horizontally and/or vertically, and scrolling performance degrades quickly when you have hundreds of rows.

My team and I recently needed to generate a heat map, with around 500 rows and between 14 and 30 columns, with each cell being one of 7 colors. When we first implemented it with an HTML table, the performance while scrolling was abysmal, and the fact that we lost the header when scrolling horizontally and the main label column when scrolling horizontally made you lose all context of what row 400—cell 25 meant.

So with that, we had three main requirements:

  1. Performant scrolling
  2. Ability to fix header row(s)
  3. Ability to fix label column(s)

Without a lot of trickery there’s just no simple way to match all three of those requirements using <table> elements.

react-virtualized to the rescue

For the first requirement, we’d already been using Brian Vaughn’s react-virtualized — an open source React library that selectively renders elements in a list based on scroll position — for scrolling a horizontal list of React components to great effect. You tell react-virtualized’s List component how to render each individual row, and it tells each row whether it should even exist (am I on the screen?) as well as what position it should be in right now based on your scroll location.

Using some trickery of bulma I was able to match the second requirement by rendering a header row of div elements, then underneath render my react-virtualized List, but the third requirement yet eluded me.

Fortunately, Brian Vaughn has a working demo of our exact requirements, using a combination of his Grid component along with a ScrollSync component.

Implementation

While the working demo was what I based everything I did on, I found it to be a little difficult to follow along with if you haven’t already been using react-virtualized, and so I set out to make a few jsFiddle’s to show four ways of rendering the same table, with the fourth way meeting our above requirements.

Every table I talk about below will use the same basic set up, except the 4th:

const rowCount = 500
const colCount = 30
const cellHeight = 50
const cellWidth = 72

const headers = new Array(colCount).fill(0).map((val, idx) => `H${idx}`)
const makeRow = row => new Array(colCount).fill(0).map((val, idx) => `R${row} C${idx}`)
const rows = new Array(rowCount).fill(0).map((val, idx) => makeRow(idx))

In this case, I’m making 500 rows with 30 columns. The header exists as an array of items, [ 'H0', 'H1', ...'H29' ] The body is an array of rows, where each row is an array of [ 'R0C0', 'R0C1', ... 'R0C29' ]

Using HTML Table

  
const Table = () =>
	<table>
	  <thead>
	    {headers.map(header => <th>{header}</th>)}
	  </thead>
    <tbody>
      {rows.map(row => <tr>
        {row.map(rowData => <td>{rowData}</td>)}
      </tr>)}
    </tbody>
	</table>

ReactDOM.render(
  <Table />,
  document.getElementById('container')
);
  

Fiddle Here

Simple HTML table, rendering each header item in a <th> tag and rendering each cell as a <td> element as needed through maps.

Using react-virtualized Grid

Re-Creating the Table with a single Grid Element

  
const headers = new Array(colCount).fill(0).map((val, idx) => `H${idx}`)
const makeRow = row => new Array(colCount).fill(0).map((val, idx) => `R${row} C${idx}`)
const rows = new Array(rowCount).fill(0).map((val, idx) => makeRow(idx))

const Cell = ({
  columnIndex,
  key,
  rowIndex,
  style,
}) =>
  <div className='grid-cell' key={key} style={style}>
    {rowIndex === 0
      ? headers[columnIndex]
      : rows[rowIndex - 1][columnIndex]}
  </div>

const Table = () =>
  <div>
    <Grid
      cellRenderer={Cell}
      columnCount={colCount}
      columnHeight={rowCount * cellHeight}
      columnWidth={cellWidth}
      height={500}
      rowCount={rowCount}
      rowHeight={cellHeight}
      rowWidth={colCount * cellWidth}
      width={colCount * cellWidth}
    />
  </div>

ReactDOM.render(
  <Table />,
  document.getElementById('container')
);
  

Fiddle Here

The same table, but rendered one cell at a time by using the Grid component from react-virtualized to give an idea of how it works.

  • cellRenderer is a function that returns a DOM element and receives as props columnIndex, key, rowIndex, and style.
    • I use the columnIndex and rowIndex to determine which cell I’m rendering right now.
    • I use the key and the style props passed in by Grid — The style prop here contains things such as height/width and position on screen, key is used so React knows it’s rendering a unique item.
  • columnCount and rowCount are, simply, the number of columns and rows this Grid will contain.

  • columnHeight and columnWidth are fairly self explanatory, but they can also be functions. In that case, the function would receive an object as an argument, containing a columnIndex value that you could use to determine if specific columns need to be wider or higher than others.

  • rowHeight and rowWidth behave exactly the same as the above.

  • height and width is the overall height and width of the entire Grid – in this case, 500px high and the number of my columns times the width of each cell wide.

To see how the virtualization works, I’d suggest inspecting the DOM in that fiddle and scrolling through the table. You will see elements vanish from the DOM and new ones take its place. This is react-virtualized working its magic, and only rendering the components you should be able to see.

react-virtualized dom changes

We’ve successfully met our first requirement, but now what about the rest?

Re-Creating the Table With Two Grid Elements

  
const Cell = ({
  columnIndex,
  key,
  rowIndex,
  style,
}) =>
  <div className='grid-cell' key={key} style={style}>
      {rows[rowIndex][columnIndex]}
  </div>

const HeaderCell = ({
  columnIndex,
  key,
  style,
}) =>
  <div className='grid-cell' key={key} style={style}>
      {headers[columnIndex]}
  </div>

const Table = () =>
  <div>
    <Grid
      cellRenderer={HeaderCell}
      columnCount={colCount}
      columnHeight={rowCount * cellHeight}
      columnWidth={cellWidth}
      height={cellHeight}
      rowCount={1}
      rowHeight={cellHeight}
      rowWidth={colCount * cellWidth}
      width={colCount * cellWidth}
    />
    <Grid
      cellRenderer={Cell}
      columnCount={colCount}
      columnHeight={rowCount * cellHeight}
      columnWidth={cellWidth}
      height={500}
      rowCount={rowCount}
      rowHeight={cellHeight}
      rowWidth={colCount * cellWidth}
      width={colCount * cellWidth + scrollbarWidth}
    />
  </div>

ReactDOM.render(
  <Table />,
  document.getElementById('container')
);
  

Fiddle Here

The same table as the single Grid, but now we’ve locked the header. When we scroll, the header will stay in place. We get the same scrolling performance benefit, but now we’re meeting the second requirement.

The data has always been two independent pieces, but now our jsx matches that.

  1. The body that scrolls both horizontally and vertically
  2. The fixed header which only scrolls horizontally

The only changes we had to make to the original table was adding a second Grid that only renders the header, and adding a specific cellRenderer function for both body cells and header cells.

Re-Creating the Table with Four Grid Elements

This is where things start getting complicated.

The setup here is a little different from the rest.

const headers = new Array(colCount - 1).fill(0).map((val, idx) => `H${idx + 1}`) // the fixed header that only scrolls horizontally
const makeRow = row => new Array(colCount - 1).fill(0).map((val, idx) => `R${row} C${idx + 1}`)
const rows = new Array(rowCount).fill(0).map((val, idx) => makeRow(idx)) // the main body
const fixedCol = new Array(rowCount - 1).fill(0).map((val, idx) => `R${idx + 1} C0`) // the fixed column that only scrolls vertically
const fixedCell = 'R0 C0' // The fixed cell that never moves

I’m splitting my data into four pieces, my rows, my fixedCol (the column that scrolls only horizontally), the fixedCell (the overlap between the fixed header row and the fixed column), and my headers which is my fixed header row.

In order to keep that data separate, I had to remove the overlap from headers by dropping the columnCount by 1, and do the same for fixedCol.

I’ll go a bit more into this, but first, the JSX.

  
const Cell = ({
  columnIndex,
  key,
  rowIndex,
  style,
}) =>
  <div className='grid-cell' key={key} style={style}>
      {rows[rowIndex][columnIndex]}
  </div>

const HeaderCell = ({
  columnIndex,
  key,
  style,
}) =>
  <div className='grid-cell' key={key} style={style}>
      {headers[columnIndex]}
  </div>

const FixedColCell = ({
  rowIndex,
  key,
  style,
}) =>
  <div className='grid-cell' key={key} style={style}>
      {fixedCol[rowIndex]}
  </div>

const FixedCell = () =>
  <div>
    {fixedCell}
  </div>

const Table = () =>
  <ScrollSync>
    {({ onScroll, scrollTop, scrollLeft }) =>
      <div>
        <div
          style={{
            position: 'absolute',
            top: 0,
            left: 0,
          }}
        >
          <Grid
            cellRenderer={FixedCell}
            columnCount={1}
            columnHeight={cellHeight}
            columnWidth={cellWidth}
            height={cellHeight}
            rowCount={1}
            rowHeight={cellHeight}
            rowWidth={cellWidth}
            width={cellWidth}
          />
        </div>
        <div
          style={{
            position: 'absolute',
            top: cellHeight,
            left: 0,
          }}
        >
          <Grid
            cellRenderer={FixedColCell}
            className={'no-scroll'}
            columnCount={1}
            columnHeight={rowCount * cellHeight}
            columnWidth={cellWidth}
            height={tableHeight}
            rowCount={rowCount - 1}
            rowHeight={cellHeight}
            rowWidth={colCount * cellWidth}
            scrollTop={scrollTop}
            width={cellWidth}
          />
        </div>
        <div
          style={{
            position: 'absolute',
            top: 0,
            left: cellWidth,
          }}
        >
          <Grid
            cellRenderer={HeaderCell}
            className={'no-scroll'}
            columnCount={colCount - 1}
            columnHeight={rowCount * cellHeight}
            columnWidth={cellWidth}
            height={cellHeight}
            rowCount={1}
            rowHeight={cellHeight}
            rowWidth={colCount * cellWidth}
            scrollLeft={scrollLeft}
            width={tableWidth}
          />
        </div>
        <div
          style={{
            position: 'absolute',
            top: cellHeight,
            left: cellWidth,
          }}
        >
          <Grid
            cellRenderer={Cell}
            columnCount={colCount - 1}
            columnHeight={rowCount * cellHeight}
            columnWidth={cellWidth}
            height={tableHeight + scrollbarWidth}
            onScroll={onScroll}
            rowCount={rowCount - 1}
            rowHeight={cellHeight}
            rowWidth={colCount * cellWidth}
            width={tableWidth + scrollbarWidth}
          />
        </div>
      </div>
    }
  </ScrollSync>
  

Fiddle Here

Once again we’re rendering the same table, but now we have a locked header and column, successfully meeting all three of our requirements, but what is actually going on?

We have to think of this table as four individual pieces.

  1. The body that scrolls both horizontally and vertically
  2. The fixed header which only scrolls horizontally
  3. The fixed column which only scrolls vertically
  4. The overlapping cell(s) between the fixed header and fixed column(s) that never move.

The easiest way to handle (in my opinion) that is to split our data into those 4 independent items, my headers, my rows, my fixedCol, and my fixedCell elements, and create independent render functions for those elements, HeaderCell, Cell, FixedColCell, and FixedCell (maybe being redundant for the sake of clarity)

Now we have to render four Grid elements, but they have to be correctly positioned on the screen relative to each other.

  • The FixedCell should be at left: 0, top: 0 which is the upper-left of our rendering area.
  • The Header should start one cell’s width to the right of FixedCell, so left: cellWidth, top: 0
  • The FixedCol should start one cell’s height beneath the FixedCell, so left: 0, top: cellHeight
  • The Body should start one cell’s height beneath FixedCell and one cell’s width to the right of FixedCell, so left: cellWidth, top: cellHeight

From there, we render our four Grid elements, telling each one how to render their respective cells and how many rows and columns each should have. It’s important to note we have to disable scroll bars on the header and fixed column cells, and in order to do that I passed in a className prop to those two Grid elements that has the overflow: hidden !important rule attached.

If that’s all we did (ignoring the ScrollSync component for the time being), then we’d be able to scroll the body but the header and columns wouldn’t scroll with us.

By wrapping everything in ScrollSync, we can get a few props, onScroll, scrollTop, and scrollLeft passed in to the underlying component (in this case, our 4 grids)

  • onScroll is a callback that we attach to the main body Grid, so that it gets called whenever the body scrolls
  • scrollLeft is the horizontal scrolling position that we need to attach to the header Grid.
  • scrollTop is the vertical scrolling position that we need to attach to the fixed column Grid.

By attaching these, whenever we scroll the body, the onScroll callback is called, updating scrollLeft and scrollTop values, and the two components those are attached to will scroll with us.

And there we have it, a 4 piece Grid, aligned correctly, with a scrollable body and a fixed header and fixed column.

You can get even more advanced from here, and lock multiple columns or rows to create any scrollable grid layout.

posted on June 29, 2017 by
Sean Kemmis