Back
Sep 8, 2023

Smooth React virtual scroll with fixed rows/columns

One of our ongoing projects, Neptyne, introduces an Excel-like grid written in React. We used a library to apply virtual scroll to it, but we stumbled upon a problem with fixed rows and columns inside the grid. Here I would like to describe this problem, how it occurs, and how we handled it.

image-20230303-082654.png

Why simple virtual scroll is not enough

The main goal of a virtual scroll is to reduce client resource consumption by rendering only visible elements of a large scrollable list/table. Since this is a common technique, there are lots of articles you can read to catch up on how it works (here, here).

However, we cannot use a simple virtual scroll to implement a table with frozen rows and columns. For better understanding, let me show you how Google Sheets implement its frozen rows/cols:

frozen-sheets.gif

As you can see, frozen rows/cols still need to be virtualized. Essentially, frozen columns A-B should have a virtual height, and rows 1-3 should have a virtual width. They must be scrollable and must not render cells out of the viewport. There is no CSS to make this work: we need to use something more complex than a single virtual scroll element.

Multiple virtual scroll windows

To make a virtualized table with frozen rows/cols, we have to treat each area as a separate table. Each of these tables has a viewport visible to the user and a “canvas“, where the content is drawn.

image-20230303-123641.png

Each table has its own set of data to be drawn. “Unfrozen“ table does not have access to content rendered in the frozen rows and columns. For example, if we have row 1 frozen, then “unfrozen“ part will work with rows 2-999, and “frozen rows” will be provided with data from row 1.

However, to achieve the effect we have seen with Google Sheets, we need to synchronize scroll states between tables. For instance, if we scroll the “unfrozen” table to the right, the frozen rows should be scrolled to the right as well. If we scroll “unfrozen“ table to the bottom, frozen columns should be scrolled to the bottom too.

We did a little research and found a couple of libraries that implemented this approach. The most prominent of them was react-virtualized - it was relatively simple and rich in features.

It implements a classic virtual scroll pattern mentioned above: we have a tiny DOM element for a viewport, which has a fixed width and height. Inside the viewport, we have a large DOM element that contains the actual grid cells. When the user scrolls, the library determines which cells are visible through a viewport, and draws only them.

That worked pretty well for us feature-wise, but there was one notable issue: scroll lag related to fixed rows and columns.

virt-scroll-lag-20230303-114919.gif

As you can see in the GIF, there is a slight lag between scrolling the main part and the fixed parts. This effect gets more apparent with weaker computers and more sophisticated grid cell components. So we had to find out the reason for this and re-implement this feature manually.

Where does the lag come from?

Frozen rows and columns came in separate tables. Each of these tables has an independent scroll state synchronized by a common parent.

So when the user scrolls any of the tables, react-virtualized reads its updated scroll position and sets the same position for the rest of the tables.

Lag comes from the fact that the browser draws scrolled content of the first table before the rest have a chance to re-render.

Solution

The solution is to somehow have the same scroll position confidence source set for all tables at the same time.

We decided to listen to onWheel event of the parent table. This event gives us the offset user scrolled for. Nothing has been scrolled so far. We then calculate the updated scroll position and set it to all tables.

The scroll state is updated and broadcasted to all the tables simultaneously, allowing them to adjust their position without the lag.

Since this “four synchronized tables“ approach is effectively hardcoded in the react-virtualized library, we moved to a lighter react-window, which is easier to customise for our case.

Below you can see an example of React-component that allows rendering tables with frozen rows and cols. The code should be covered with comments well enough to understand what is going on, but here is a brief recap anyway.

We render four VariableSizeGrid that reflect four tables with data;

Three of them are “frozen” tables - frozen rows, frozen cols, and this frozen rectangle in upper left part if we have both frozen rows and cols;

All “frozen“ parts have a CSS class scrollable-grid that prevents them from scrolling natively;

The main table can be scrolled freely. It simplifies the flow if we only have unfrozen part, and does not affect the scrolling of frozen parts since we don’t synchronize them the usual way;

We also have an outer-grid-container DOM element, that handles scroll synchronization. It owns an event handler described on line 232. This handler listens to any scroll event inside its children, simultaneously applying scroll to every grid that we have.

So, in order to synchronize the scrolling of several tables, you basically need to “mute“ scroll event in the tables and listen to scroll in a parent element. This would allow you to programmatically change the scroll state to all the tables, giving you fairly smooth scrolling.

// css
// .scrollable-grid {
//   overflow: scroll;
//   -ms-overflow-style: none;
//
//   // prohibit scroll for "frozen" grid containers
//   scrollbar-width: none;
// }

// .scrollable-grid::-webkit-scrollbar {
//   display: none;
// }

// .main-grid {
//   display: flex;
// }

import {
  areEqual,
  GridChildComponentProps,
  GridOnScrollProps,
  VariableSizeGrid,
} from "react-window";
import {
  CSSProperties,
  FunctionComponent,
  memo,
  useCallback,
  useEffect,
  useMemo,
  useRef,
} from "react";
import { ReactWindowCellRenderer } from "./react-window-cell/ReactWindowCellRenderer";

interface Properties {
  className?: string;
  columnWidth: (index: number) => number;
  rowHeight: (index: number) => number;
  columnCount: number;
  rowCount: number;
  width: number;
  height: number;

  frozenRowCount: number;
  frozenColumnCount: number;

  hideScrollbars?: boolean;

  gridData: unknown;
}

interface OffsetCellRendererProps extends GridChildComponentProps {
  rowOffset: number;
  colOffset: number;
}

const HORIZONTAL_TABLE_BLOCK_STYLE: CSSProperties = {
  display: "flex",
  flexDirection: "row",
};

// special cell renderer to reflect cells with particular position. Offset allows to "skip"
// rendering first "frozen" cells.
const OffsetCellRenderer: FunctionComponent = memo(
  ({ rowOffset, colOffset, ...props }) => (
    
  ),
  areEqual
);

export const VirtualizedGrid = (props: Properties) => {
  // number of rows and columns that should be rendered in separate "frozen" tables
  const { frozenRowCount, frozenColumnCount, className } = props;

  // calculator functions to get size of particular row and column.
  // Used to calculate sizes of frozen/unfrozen tables for virtual scroll.
  const { rowHeight, columnWidth } = props;

  // the object that contains business data of an app. It can contain some parameters to be applied
  // to the cells, and the actial cell data in a form of two-dimensional array.
  const { gridData } = props;

  // refs to actual containers that display data
  const cornerRef = useRef(null);
  const topHeaderRef = useRef(null);
  const leftHeaderRef = useRef(null);
  const mainGridRef = useRef(null);

  // ref to a "canvas" div with size of entire grid. The actual DOM element that we scroll
  const mainGridContainerRef = useRef(null);

  // a special element that listens to scroll events and dispatches them to actual grid containers
  const outerContainerRef = useRef(null);

  useEffect(
    () => {
      const { current: grid } = mainGridRef;
      const { current: top } = topHeaderRef;
      const { current: left } = leftHeaderRef;
      if (grid && top && left) {
        grid.scrollTo({ scrollLeft: 0, scrollTop: 0 });
        top.scrollTo({ scrollLeft: 0 });
        left.scrollTo({ scrollTop: 0 });
      }
    },
    [] /* only on initial render, make sure we're at grid origin */
  );

  // calculate summarized sizes of frozen rows/cols
  const frozenRowHeight = useMemo(() => {
    let height = 0;
    for (let i = 0; i < frozenRowCount; i++) {
      height += rowHeight(i);
    }
    return height;
  }, [frozenRowCount, rowHeight]);
  const frozenColWidth = useMemo(() => {
    let width = 0;
    for (let i = 0; i < frozenColumnCount; i++) {
      width += columnWidth(i);
    }
    return width;
  }, [columnWidth, frozenColumnCount]);

  // calculate summarized sizes of unfrozen rows/cols
  const unfrozenHeight = props.height - frozenRowHeight;
  const unfrozenWidth = props.width - frozenColWidth;

  const cornerGridStyle = useMemo(
    (): CSSProperties => ({
      display: "flex",
      ...(frozenColumnCount > 1 ? { borderRight: "2px solid gray" } : {}),
      ...(frozenRowCount > 1 ? { borderBottom: "2px solid gray" } : {}),
    }),
    [frozenColumnCount, frozenRowCount]
  );

  const topGridStyle = useMemo(
    (): CSSProperties => ({
      display: "flex",
      ...(frozenRowCount > 1 ? { borderBottom: "2px solid gray" } : {}),
    }),
    [frozenRowCount]
  );

  const leftGridStyle = useMemo(
    (): CSSProperties => ({
      display: "flex",
      ...(frozenColumnCount > 1 ? { borderRight: "2px solid gray" } : {}),
    }),
    [frozenColumnCount]
  );

  const offsetColumnWidth = useCallback(
    (index: number) => columnWidth(index + frozenColumnCount),
    [columnWidth, frozenColumnCount]
  );
  const offsetRowHeight = useCallback(
    (index: number) => rowHeight(index + frozenRowCount),
    [frozenRowCount, rowHeight]
  );

  // cell renderers
  const FrozenCornerRenderer = useCallback(
    (props: GridChildComponentProps) => (
      
    ),
    []
  );
  const FrozenRowRenderer = useCallback(
    (props: GridChildComponentProps) => (
      
    ),
    [frozenColumnCount]
  );
  const FrozenColumnRenderer = useCallback(
    (props: GridChildComponentProps) => (
      
    ),
    [frozenRowCount]
  );
  const FrozenCellRenderer = useCallback(
    (props: GridChildComponentProps) => (
      
    ),
    [frozenColumnCount, frozenRowCount]
  );

  const handleScroll = useCallback(({ scrollLeft, scrollTop }: GridOnScrollProps) => {
    topHeaderRef.current?.scrollTo({ scrollLeft });
    leftHeaderRef.current?.scrollTo({ scrollTop });
  }, []);

  const handleVerticalScroll = useCallback(({ scrollTop }: GridOnScrollProps) => {
    mainGridRef.current?.scrollTo({ scrollTop });
  }, []);

  const handleHorizontalScroll = useCallback(({ scrollLeft }: GridOnScrollProps) => {
    mainGridRef.current?.scrollTo({ scrollLeft });
  }, []);

  const outerGridStyle = { width: "100%", height: "100%" };

  // calculate total grid size
  const [gridHeight, gridWidth] = useMemo(() => {
    let height = 0;
    let width = 0;
    for (let i = 0; i < props.rowCount; i++) {
      height += rowHeight(i);
    }
    for (let i = 0; i < props.columnCount; i++) {
      width += columnWidth(i);
    }
    return [height, width];
  }, [columnWidth, rowHeight, props.columnCount, props.rowCount]);

  // maximum value in pixels to which we can scroll
  const maxVScroll = gridHeight - frozenRowHeight - unfrozenHeight;
  const maxHScroll = gridWidth - frozenColWidth - unfrozenWidth;

  // The magic happens here. We listen to scroll event in an external div, and apply scroll data to
  // grids. This allows us to synchronize scroll events between grids, applying changes in one
  // render "tick".
  useEffect(() => {
    const { current: outerGrid } = outerContainerRef;
    if (outerGrid) {
      const handler = (e: WheelEvent) => {
        e.preventDefault();
        const { deltaX, deltaY } = e;
        const { current: grid } = mainGridRef;
        const { current: top } = topHeaderRef;
        const { current: left } = leftHeaderRef;
        const { current: gridDiv } = mainGridContainerRef;
        if (gridDiv && grid && top && left) {
          let { scrollLeft, scrollTop } = gridDiv;
          scrollLeft += deltaX;
          scrollTop += deltaY;
          if (scrollLeft > maxHScroll) {
            scrollLeft = maxHScroll;
          }
          if (scrollTop > maxVScroll) {
            scrollTop = maxVScroll;
          }
          grid.scrollTo({ scrollLeft, scrollTop });
          top.scrollTo({ scrollLeft });
          left.scrollTo({ scrollTop });
        }
      };
      outerGrid.addEventListener("wheel", handler);
      return () => outerGrid.removeEventListener("wheel", handler);
    }
  });

  const unfrozenHorizontalDivStyles = useMemo(
    (): CSSProperties => ({ position: "absolute", left: frozenColWidth, top: 0 }),
    [frozenColWidth]
  );
  const mainGridInnerContainerStyles = useMemo(
    (): CSSProperties => ({
      position: "absolute",
      left: frozenColWidth,
      top: frozenRowHeight,
    }),
    [frozenColWidth, frozenRowHeight]
  );

  return (
    
{FrozenCornerRenderer}
{FrozenRowRenderer}
{FrozenColumnRenderer}
{FrozenCellRenderer}
); };
fast-frozen-scroll-20230303-155855.gif

This approach allowed us to provide smooth scrolling of frozen rows and columns while simultaneously reusing many of the features of a third-party virtual scroll library. Since this ideological approach relies on pure event handlers and HTML/CSS, it should be applicable to virtual scrolls with other libraries and frameworks.

Hope this article helps you, happy coding!

Subscribe for the news and updates

More thoughts
Mar 18, 2024Technology
From boring to exciting: turn learning to code into an adventure

Tired of boring programming courses where you're forced to read thick textbooks and write code that's never used? Need a platform that makes learning fun and exciting? Then you're in the right place!

Sep 26, 2023TechnologyBusiness
13 Web Development Innovations to Adopt in the Next Year

Web development has undergone significant changes over the past five years due to a combination of technological advancements and changing user needs. Let's look at the drivers of these changes and the key technologies that have played a decisive role.

Apr 27, 2022TechnologyBusiness
How to Choose the Best Javascript Framework: Comparison of the Top Javascript Frameworks

In our article, you will find the best JavaScript framework comparison so that you know for sure how to choose the right one for your project.

May 22, 2017Technology
Web Application Security: 10 Best Practices

Protection of WEB App is of paramount importance and it should be afforded the same level of security as the intellectual rights or private property. I'm going to cover how to protect your web app.

Jan 28, 2017Technology
Creating a site preview like in slack (using aiohttp)

In this article we will write a small library for pulling metadata and creating a preview for a site just like Slack does.

Oct 3, 2016Technology
How to include JQuery plugins in Angular 2 running via webpack

Learn more about how to include jquery plugins in angular 2 running via webpack. Our tutorial is perfect for Angular beginners.