Back to Blog
React Data Grids: Beyond the Marketing Demo, Benchmarking Real-World Performance
10 min readJul 6, 20262 views

React Data Grids: Beyond the Marketing Demo, Benchmarking Real-World Performance

When your client's dataset grows from hundreds to tens of thousands of rows, that slick data grid component you picked suddenly turns into a janky, unresponsive nightmare. Most marketing demos for React data grids show off beautiful UIs with trivial amounts of data. But what happens when you throw 5

ReactFrontendPerformanceUIWeb Development
Share

by Sunil Band

The Illusion of Infinite Scroll

Every data grid demo looks incredible with twenty rows. The columns line up. The hover state is buttery smooth. Filtering and sorting feel instantaneous. Then you get it into your project, connect it to a real backend, and suddenly your users are complaining about jank, freezes, and a general feeling of sluggishness. The culprit? Often, it's not your backend or your network, but the very component meant to display all that data: the React data grid itself.

I've spent countless hours trying to optimize client-side data grids, often inheriting systems where a grid was chosen based on aesthetics or a feature list, with little thought given to its performance under load. When dealing with hundreds of thousands of rows, the naive approach of rendering every single DOM element for every row is a non-starter. This is where virtual scrolling, or windowing, comes in. It's not optional; it's fundamental.

Virtual scrolling renders only the rows currently visible within the viewport, plus a few buffer rows above and below. As the user scrolls, the grid dynamically re-renders rows, recycling DOM elements to maintain a fluid experience. This sounds simple, but the implementation details are where many libraries shine or fall apart. It's not just about what it does, but how well it does it – how efficiently it calculates visible ranges, how smoothly it handles scroll events, and how it deals with variable row heights.

Why Most Benchmarks Miss the Point

When I see benchmarks for data grids, they often focus on initial render time or a simple scroll through a fixed dataset. These are good starting points, but they don't tell the whole story. Real-world applications have dynamic data, user interactions beyond just scrolling, and often complex cell renderers.

The critical performance bottlenecks often emerge when you combine these factors:

  • Large datasets (50,000+ rows): This is the baseline for testing virtual scrolling's efficiency.
  • Variable row heights: Some grids struggle when rows aren't uniformly sized, leading to choppy scrolling or incorrect scrollbar behavior.
  • Complex cell renderers: Custom components inside cells, especially those with their own state or event handlers, can quickly degrade performance.
  • Frequent data updates: Imagine a grid displaying real-time stock prices or sensor data. How does it handle hundreds of updates per second?
  • Filtering and sorting: How quickly does the grid re-render after these operations, especially if they trigger a recalculation of the virtualized window?

My approach to benchmarking goes beyond simple numbers. I want to feel the grid. Does it feel responsive? Does it jank? Can I type into a cell quickly after scrolling? These qualitative aspects are just as important as quantitative metrics for user experience.

Diving into a Realistic Benchmark Scenario

Let's consider a practical scenario: displaying a list of orders, each with a unique ID, customer name, order date, status, and an action button. We'll simulate 50,000 orders. Each cell will have reasonably complex content, and one column will have an interactive element. This isn't just about rendering text; it's about rendering interactive components.

I’m going to use react-virtualized as a baseline for what a performant virtualized list can do, and then talk about how commercial/feature-rich grids compare. react-virtualized is a low-level library, so it's not a direct competitor to a full-fledged data grid, but it sets the bar for virtualization.

First, let's generate some realistic data:

typescript
// dataGenerator.ts
export interface Order {
  id: string;
  customerName: string;
  orderDate: string;
  status: 'pending' | 'shipped' | 'delivered' | 'cancelled';
  totalAmount: number;
  productCount: number;
}

const generateRandomOrder = (index: number): Order => ({
  id: `ORD-${String(index).padStart(5, '0')}`,
  customerName: `Customer ${Math.floor(Math.random() * 1000)}`,
  orderDate: new Date(Date.now() - Math.random() * 1000 * 60 * 60 * 24 * 365).toISOString().split('T')[0],
  status: ['pending', 'shipped', 'delivered', 'cancelled'][Math.floor(Math.random() * 4)] as Order['status'],
  totalAmount: parseFloat((Math.random() * 1000 + 50).toFixed(2)),
  productCount: Math.floor(Math.random() * 10) + 1,
});

export const generateOrders = (count: number): Order[] => {
  const orders: Order[] = [];
  for (let i = 0; i < count; i++) {
    orders.push(generateRandomOrder(i));
  }
  return orders;
};

Now, let's set up a basic react-virtualized list. This gives us a raw, performant virtualization layer. Notice the rowRenderer and cellDataGetter – this is where you'd put your complex cell logic, but for a true benchmark, we want to see the overhead of the grid itself.

tsx
// VirtualizedOrderList.tsx
import React, { useCallback, useMemo } from 'react';
import { FixedSizeList } from 'react-window'; // More modern than react-virtualized's List, easier to reason about fixed heights
import { generateOrders, Order } from './dataGenerator';

const ITEM_COUNT = 50000;
const ITEM_HEIGHT = 50; // Fixed height for simplicity in react-window

const Row = ({ index, style, data }: { index: number; style: React.CSSProperties; data: Order[] }) => {
  const order = data[index];

  const handleViewDetails = useCallback(() => {
    console.log(`Viewing details for order ${order.id}`);
  }, [order.id]);

  return (
    <div style={{ ...style, display: 'flex', alignItems: 'center', borderBottom: '1px solid #eee', padding: '0 10px' }}>
      <div style={{ width: '150px' }}>{order.id}</div>
      <div style={{ width: '200px' }}>{order.customerName}</div>
      <div style={{ width: '120px' }}>{order.orderDate}</div>
      <div style={{ width: '100px' }}>{order.status}</div>
      <div style={{ width: '100px', textAlign: 'right' }}>${order.totalAmount.toFixed(2)}</div>
      <div style={{ flexGrow: 1, textAlign: 'right' }}>
        <button onClick={handleViewDetails} style={{ padding: '4px 8px', cursor: 'pointer' }}>
          View
        </button>
      </div>
    </div>
  );
};

export const VirtualizedOrderList: React.FC = () => {
  const orders = useMemo(() => generateOrders(ITEM_COUNT), []);

  return (
    <div style={{ height: '500px', width: '100%', border: '1px solid #ccc' }}>
      <h3>React-Window List ({ITEM_COUNT} rows)</h3>
      <FixedSizeList
        height={500} // Visible height of the list
        width="100%" // Width of the list
        itemCount={ITEM_COUNT} // Total number of items
        itemSize={ITEM_HEIGHT} // Height of each item
        itemData={orders} // Data passed to the Row component
      >
        {Row}
      </FixedSizeList>
    </div>
  );
};

This react-window example is highly optimized for a simple, fixed-height list. It's incredibly fast, even with 50,000 rows. Scrolling is fluid, CPU usage is minimal. This is the gold standard for pure virtualization. But it’s just a list, not a full-fledged grid with column headers, resizing, sorting indicators, and complex cell types.

The Real-World Grid Test: AG Grid vs. TanStack Table

When you move to a production-ready grid, you need features like column resizing, sorting, filtering UIs, grouping, and potentially complex cell editors. These features add overhead, and it's how efficiently a grid manages this overhead that separates the good from the bad.

I've worked with AG Grid extensively. It's incredibly powerful and performant if configured correctly. Its enterprise version offers server-side row models, which are essential for truly massive datasets (millions of rows), but even its client-side row model with virtualization is robust. However, it's also a heavyweight dependency with a steep learning curve and a distinct API that feels less 'React-native' than some alternatives.

On the other end, we have libraries like TanStack Table (formerly React Table). This is a headless library, meaning it provides the logic for tables but leaves the rendering completely up to you. This gives you maximum flexibility and control, but also means you have to build all the UI yourself – headers, cells, pagination controls, etc. For performance, this is often a win because you can optimize your rendering precisely. The trade-off is development time.

Let's sketch out how a TanStack Table setup might look for our orders data. This highlights the boilerplate, but also the control.

tsx
// TanstackOrderTable.tsx
import React, { useMemo, useState, useCallback } from 'react';
import { 
  useReactTable,
  getCoreRowModel,
  flexRender,
  ColumnDef,
} from '@tanstack/react-table';
import { generateOrders, Order } from './dataGenerator';

const ITEM_COUNT = 50000;

export const TanstackOrderTable: React.FC = () => {
  const [data] = useState(() => generateOrders(ITEM_COUNT));

  // Define columns - this is where you customize rendering
  const columns = useMemo<ColumnDef<Order>[]>(() => [
    {
      accessorKey: 'id',
      header: 'Order ID',
      cell: info => <span style={{ fontWeight: 'bold' }}>{info.getValue() as string}</span>,
      size: 150,
    },
    {
      accessorKey: 'customerName',
      header: 'Customer',
      size: 200,
    },
    {
      accessorKey: 'orderDate',
      header: 'Date',
      size: 120,
    },
    {
      accessorKey: 'status',
      header: 'Status',
      cell: info => {
        const status = info.getValue() as Order['status'];
        let color = 'black';
        if (status === 'delivered') color = 'green';
        if (status === 'pending') color = 'orange';
        if (status === 'cancelled') color = 'red';
        return <span style={{ color }}>{status}</span>;
      },
      size: 100,
    },
    {
      accessorKey: 'totalAmount',
      header: 'Total',
      cell: info => `$${(info.getValue() as number).toFixed(2)}`,
      size: 100,
      meta: { align: 'right' },
    },
    {
      id: 'actions',
      header: 'Actions',
      cell: ({ row }) => {
        const handleViewDetails = useCallback(() => {
          console.log(`Viewing details for order ${row.original.id}`);
        }, [row.original.id]);
        return (
          <button onClick={handleViewDetails} style={{ padding: '4px 8px', cursor: 'pointer' }}>
            View
          </button>
        );
      },
      size: 100,
      enableSorting: false, // Actions column typically isn't sortable
    },
  ], []);

  const table = useReactTable({
    data,
    columns,
    getCoreRowModel: getCoreRowModel(),
    // You'd add virtualization here, often with a separate library like react-virtual or tanstack/react-virtual
    // For this example, we're focusing on the table *logic* part, not the UI virtualization.
    // A real implementation would combine this with a virtualized scroll container.
  });

  // This part would be wrapped in a virtualized container for large datasets
  // For illustration, showing a basic render without virtualization for now
  return (
    <div style={{ height: '500px', overflowY: 'auto', border: '1px solid #ccc' }}>
      <h3>TanStack Table ({ITEM_COUNT} rows - *UI virtualization would be added*)</h3>
      <table style={{ width: '100%', borderCollapse: 'collapse' }}>
        <thead>
          {table.getHeaderGroups().map(headerGroup => (
            <tr key={headerGroup.id}>
              {headerGroup.headers.map(header => (
                <th 
                  key={header.id} 
                  colSpan={header.colSpan} 
                  style={{
                    width: header.getSize(), 
                    borderBottom: '2px solid #ddd', 
                    padding: '8px', 
                    textAlign: header.column.columnDef.meta?.align || 'left'
                  }}
                >
                  {header.isPlaceholder
                    ? null
                    : flexRender(
                        header.column.columnDef.header,
                        header.getContext()
                      )}
                </th>
              ))}
            </tr>
          ))}
        </thead>
        <tbody>
          {table.getRowModel().rows.map(row => (
            <tr key={row.id} style={{ borderBottom: '1px solid #eee' }}>
              {row.getVisibleCells().map(cell => (
                <td 
                  key={cell.id} 
                  style={{
                    width: cell.column.getSize(), 
                    padding: '8px', 
                    textAlign: cell.column.columnDef.meta?.align || 'left'
                  }}
                >
                  {flexRender(cell.column.columnDef.cell, cell.getContext())}
                </td>
              ))}
            </tr>
          ))}
        </tbody>
      </table>
    </div>
  );
};

Notice the comment in the TanstackOrderTable example. While TanStack Table provides the data and column management logic, it does not include UI virtualization out of the box. You'd typically combine it with tanstack/react-virtual (or react-window as I showed earlier) to achieve high performance. This separation is powerful: you get a highly optimized data layer and complete control over the UI, allowing you to tailor virtualization to your exact needs, including variable row heights if necessary.

AG Grid, on the other hand, comes with its own virtualization engine built-in. It's a black box, but a highly optimized one. You configure it, and it generally works well. The trade-off is less direct control and a heavier bundle size.

The Trade-offs of Feature-Rich Grids

When evaluating a full-featured grid like AG Grid, you're buying into a whole ecosystem. Here's where performance trade-offs appear:

  1. Bundle Size: Full-featured grids often have significant bundle sizes, impacting initial load time. This is less of an issue for internal tools but can be critical for public-facing applications.
  2. Over-rendering: Even with virtualization, some grids might render more than strictly necessary, especially for complex headers, footers, or sticky columns. Each extra React component mount/update adds overhead.
  3. Customization vs. Performance: The more you customize a grid's cells or behavior, the more you risk introducing your own performance bottlenecks. If the grid's internal virtualization isn't aware of your custom rendering, it can't optimize it.
  4. Learning Curve & API Lock-in: Mastering a complex grid's API takes time. Migrating away from a deeply integrated grid can be a nightmare.

My benchmarks consistently show that AG Grid, when configured correctly for virtualization, performs exceptionally well for large datasets, often on par with a react-window combined with a basic table for raw scrolling performance. However, its overall package size and the cognitive load of its API are higher. TanStack Table, when combined with a virtualization library, offers similar raw performance with much greater flexibility and a more idiomatic React API, but requires more initial setup.

My Take: Choose Your Battles Wisely

For internal tools where bundle size isn't a top priority and development speed for feature-rich tables is key, AG Grid (or similar commercial offerings like DevExtreme, Kendo UI) often wins. The sheer number of features out-of-the-box saves immense development time, and their virtualization is robust.

For public-facing applications or scenarios where maximum control, minimal bundle size, and a highly customized UI are paramount, a headless library like TanStack Table combined with a dedicated virtualization library (like react-window or tanstack/react-virtual) is usually the better, albeit more labor-intensive, choice. You get to build exactly what you need, with no bloat.

It's not about finding the 'winner' in a simple benchmark. It's about understanding the specific needs of your project, the size and complexity of your data, and the long-term maintainability. Don't be fooled by the pretty demos; always stress-test your chosen grid with realistic data and interaction patterns before committing.

Wrapping up

Performance in data grids isn't a feature; it's a foundation. If you're building a new application with substantial data display, take an hour and set up a simple react-window or tanstack/react-virtual component with 50,000 dummy rows and a few complex cells. Use that as your baseline. Then, try integrating your chosen data grid library with the same dataset and compare the feel and browser performance metrics. You'll quickly discover which ones truly handle the load gracefully and which are just pretty faces. It’s the only way to avoid a painful refactor down the line. Start by cloning the react-window example I provided, populate it with your own data structure, and tweak the Row component to match your intended cell complexity. See how it performs on your machine. That's your true north for client-side grid performance.

More from the blog
Available for projectsReady to make something fun 🎈

Ready to build the next system?Wanna build something awesome together?

Currently accepting high-impact opportunities in frontend engineering and scalable web applications.Got a cool idea rattling around? Let's grab a virtual coffee and turn it into something people love. ☕