How to make a godforsaken dynamic width scroll table with React with RxJS

  • 1
    Like
  • 0
    Comment
More than 1 year has passed since last update.

Lots of people have very romantic thoughts about building the perfect web app with everything flowing so smoothly, with very clean definitions of what is to be expected where. A lot of people also believe in creating React components that have all of their JSX in the render method, where they can be sure that a lot of the other lifecycle events that aren't too pleasant or "declarative" can be avoided.

Unfortunately, this is not one of those posts. I make heavy use of very ugly parts of React and modify the DOM as I see fit in order to do what is easiest for me to do. And really, if a components shits all over your DOM but still adheres to the component tree and cleans up after itself, was there really shit in the forest? Probably, but let's not worry about the that for now.

"Requirements"/"Specifications"

Our godforsaken dynamic width scroll table (to be called "crappy table" from here on) needs to do the following things:

  • be able to account for fixed-width columns while dynamically sizing the widths that have not been specified
  • be "fast" by letting the user just scroll up and down forever
  • take less than 4s to "load" a lot of rows
  • have a mental model that's easy to reason about

"Math"

I hate complicated crap, so here's my math for figuring out how to size my columns:

definitions:
  totalWidth = sumOfFixedWidthColumnWidths + (sumOfDynamicWidthColumnWidths)
  sumOfDynamicWidthColumnWidths = numOfDynamicWidthColumns * avgWidthOfDynamicWidthColumns

figuring out my dynamic width column widths:
  totalWidth - sumOfFixedWidthColumnWidths = numOfDynamicWidthColumns * widthOfDynamicWidthColumns
  widthOfDynamicWidthColumns = (totalWidth - sumOfFixedWidthColumnWidths) / numOfDynamicWidthColumns

Holy crap, all those years of school I did is finally paying off! I can do algebra! I'm a genius!

As for figuring out how to make this crap fast to render, I'm somewhat sure there's no real way to make anything faster in computers other than to make computers do less. So I'm going to just prevent rendering of rows that aren't going to be visible, I think. I'm too lazy to figure this out, so I'm just going to use prior art.

Getting started

How the hell do I know the width of a container anyway? This sounds way too hard to do the actual calculation for, especially with other factors like styling involved. I will know two of my values above beforehand though: sumOfFixedWidthColumnWidths and numOfDynamicWidthColumns. Hmm, I know. I'll just render this damn container to the DOM and then get the width from there! link

<div className="fixed-scroll-element-container">
  <div
    ref="HeaderContainer"
    className="fixed-scroll-element-header"/>

  <div
    ref="Container"
    className="fixed-scroll-element"
    style={containerStyle}/>
</div>

After this renders, I can just get the width from the actual nodes and calculate my column widths. link1 link2

// rowWidth: number -- the width of our row
// columnWidths: Array -- an array with the widths of the columns we're going to be using, where a real number will be in there if we have a width.
function getColumnWidths(rowWidth, columnWidths) {
  var computation = columnWidths.reduce(function (agg, width) {
    if (typeof width === 'number') {
      agg.remainingWidth -= width;
      agg.customWidthColumns -= 1;
    }
    return agg;
  }, {
    autoSizeColumns: columnWidths.length,
    remainingWidth: rowWidth
  });

  var standardWidth = computation.remainingWidth / computation.autoSizeColumns;

  return columnWidths.map(function (width) {
    if (width) {
      return width;
    } else {
      return standardWidth;
    }
  });
}

var containerWidth = this.containerNode.offsetWidth;
var computedColumnWidths = getColumnWidths(containerWidth, this.props.columnWidths);

With the column widths figured out, we can then do the deferred rendering onto the container to actually render what we have. link1 link2

deferredRender: function (columnWidths, containerWidth) {
  // this is the render for when the container has been rendered
  // and we know explicitly the container width
  var rows = this.state.visibleIndices.map((itemIndex, keyIndex) => {
    var top = itemIndex * this.props.rowHeight;
    return this.props.rowGetter(itemIndex, keyIndex, top, columnWidths, containerWidth);
  });

  return (
    <table
      style={{height: this.props.rowCount * this.props.rowHeight}}>
      <tbody>
        {rows}
      </tbody>
    </table>
  );
},

var output = this.deferredRender(computedColumnWidths, containerWidth);
React.render(output, this.containerNode);

Oops, looks like I haven't talked about how to get 'visible indices' yet.

RxJS to get all this crap together

Just like that article I linked earlier, I want to only calculate the visible indices. On the initial render, I want to grab the scroll position inside the container, and then on every scroll event, I want the same function to be called to get the scroll position.

At first, I thought doing this would be hard, but then I remembered RxJS does everything for me. link

setUpTable: function () {
  var containerHeight = this.props.containerHeight;
  var rowHeight = this.props.rowHeight;
  var rowCount = this.props.rowCount;

  var visibleRows = Math.ceil(containerHeight/ rowHeight);

  var getScrollTop = () => {
    return this.containerNode.scrollTop;
  };

  var initialScrollSubject = new Rx.ReplaySubject(1);
  initialScrollSubject.onNext(getScrollTop());

  var scrollTopStream = initialScrollSubject.merge(
    Rx.Observable.fromEvent(this.containerNode, 'scroll').map(getScrollTop)
  );

  var firstVisibleRowStream = scrollTopStream.map(function (scrollTop) {
    return Math.floor(scrollTop / rowHeight);
  }).distinctUntilChanged();

  var visibleIndicesStream = firstVisibleRowStream.map(function (firstRow) {
    var visibleIndices = [];
    var lastRow = firstRow + visibleRows + 1;

    if (lastRow > rowCount) {
      firstRow -= lastRow - rowCount;
    }

    for (var i = 0; i <= visibleRows; i++) {
      visibleIndices.push(i + firstRow);
    }
    return visibleIndices;
  });

  this.visibleIndicesSubscription = visibleIndicesStream.subscribe((indices) => {
    this.setState({
      visibleIndices: indices
    });
  });
}

And so we calculate the visible indices based on the height of the container and how many rows will fit in the container, and then we set the component state for how many indices we should display. Easy enough, right?

The other thing we need to tackle -- the godforsaken dynamic width part. On every window resize, we basically need to force an update of our component, and the width computation will get done to figure it out (we could probably move width calculation into setUpTable, but I was too lazy to do that). Well, this is too easy. link

var windowResizeStream = Rx.Observable.fromEvent(window, 'resize').debounce(50);
this.windowResizeSubscription = windowResizeStream.subscribe(() => {
  this.forceUpdate();
});

Wrapping it up

So now that we have everything set up in our crappy table component, the only thing left to do is to actually use it! There's not much informative here, so I'll just link it: https://github.com/justinwoo/godforsaken-dynamic-width-scroll-table/blob/ff872346dab5af66e337e8af718d8e41a401c4e8/example/src/index.js

Screenshots of this in action:

a.png
before resizing

b.png
after resizing

As we set in our columns widths, the first column stays at one width, while the other two are resized as the window resizes.

And the scrolling...

Screen Recording 2015-06-26 at 01.18 PM.gif

Well, you can't tell it's smooth from the GIF, but I promise you, it is very smooth. Actually, you don't have to take my word for it, check out the demo yourself!

Hope this was at least somewhat useful for somebody out there! Shout at me at Twitter if you think this is useless/useful!

My Links

References