DEV Community

Tamiz Uddin
Tamiz Uddin

Posted on Originally published at tamiz.pro

Beyond 1,000 Rows: Building a TypeScript Data Grid That Actually Scales Across React, Vue, and Angular

Originally published on tamiz.pro.

Most data grids start dying the moment you cross a thousand rows. The DOM chokes, the scroll janks, and your framework's reactivity system turns into a bottleneck. The fix isn't a library—it's an architecture. In this deep-dive, we'll build a framework-agnostic TypeScript data grid core that handles 100,000+ rows smoothly, then show how to plug it into React, Vue, and Angular without rewriting the logic.

1. Why Data Grids Break Past 1,000 Rows

A typical grid renders one <tr> per row. At 1,000 rows, that's 1,000 DOM nodes plus cells—already heavy but manageable. At 10,000 rows, you're pushing 100,000+ nodes, and the browser's layout engine spends more time recalculating styles than painting content. The problem compounds with reactive frameworks: every state change triggers a diff across the entire tree.

Three bottlenecks dominate:

  1. DOM size — The browser must create, style, and layout every node. Even if you render once, a 10,000-row table costs hundreds of milliseconds to paint.
  2. Framework reconciliation — React's virtual DOM, Vue's reactivity, and Angular's change detection all iterate over the full data set on updates.
  3. Scroll performance — Native scroll events fire at 60+ Hz. Without virtualization, every scroll event forces a full re-render.

The solution isn't to optimize the framework—it's to sidestep it. We'll build a core that manages data, selection, sorting, and virtualization in plain TypeScript. The framework layer becomes a thin adapter that only renders what's visible.

2. Architecture: Framework-Agnostic Core

Our design uses a core-plus-adapter pattern. The core is pure TypeScript, framework-agnostic, and testable. The adapter handles DOM mounting and framework-specific reactivity.

┌─────────────────────────────────────────────┐
│              Framework Adapter              │
│  (React / Vue / Angular)                    │
│  - Subscribes to core events                │
│  - Renders visible rows only                │
│  - Handles user interactions                │
└──────────────────────┬──────────────────────┘
                       │
                       │  events / commands
                       ▼
┌─────────────────────────────────────────────┐
│                 Grid Core                   │
│  - Data store (rows, columns)               │
│  - Selection & sorting state                │
│  - Virtualization math                      │
│  - Event emitter                            │
│  - Pure functions, no DOM                   │
└─────────────────────────────────────────────┘
Enter fullscreen mode Exit fullscreen mode

The core exposes a simple API:

// core/types.ts
export interface GridColumn<T> {
  key: keyof T & string;
  header: string;
  width: number;
  sortable?: boolean;
  renderer?: (value: T[keyof T], row: T) => string;
}

export interface GridState<T> {
  rows: T[];
  columns: GridColumn<T>[];
  sortKey: keyof T | null;
  sortDirection: 'asc' | 'desc' | null;
  selectedRowIds: Set<string>;
  scrollTop: number;
  viewportHeight: number;
  rowHeight: number;
}

export interface GridEvent<T> {
  type: 'row-click' | 'sort' | 'selection-change' | 'scroll';
  payload?: T | keyof T | number;
}
Enter fullscreen mode Exit fullscreen mode

The core is a class that maintains state and emits change events:

// core/GridCore.ts
import { GridColumn, GridEvent, GridState } from './types';

export class GridCore<T extends { id: string }> {
  private state: GridState<T>;
  private listeners: Array<(event: GridEvent<T>) => void> = [];

  constructor(rows: T[], columns: GridColumn<T>[], rowHeight = 40) {
    this.state = {
      rows,
      columns,
      sortKey: null,
      sortDirection: null,
      selectedRowIds: new Set(),
      scrollTop: 0,
      viewportHeight: 600,
      rowHeight,
    };
  }

  subscribe(listener: (event: GridEvent<T>) => void) {
    this.listeners.push(listener);
    return () => {
      this.listeners = this.listeners.filter((l) => l !== listener);
    };
  }

  private emit(event: GridEvent<T>) {
    this.listeners.forEach((l) => l(event));
  }

  setRows(rows: T[]) {
    this.state.rows = rows;
    this.emit({ type: 'selection-change' }); // generic refresh
  }

  sortBy(key: keyof T) {
    if (this.state.sortKey === key) {
      this.state.sortDirection = this.state.sortDirection === 'asc' ? 'desc' : 'asc';
    } else {
      this.state.sortKey = key;
      this.state.sortDirection = 'asc';
    }
    const dir = this.state.sortDirection === 'asc' ? 1 : -1;
    this.state.rows = [...this.state.rows].sort((a, b) => {
      if (a[key] < b[key]) return -1 * dir;
      if (a[key] > b[key]) return 1 * dir;
      return 0;
    });
    this.emit({ type: 'sort', payload: key });
  }

  selectRow(id: string) {
    if (this.state.selectedRowIds.has(id)) {
      this.state.selectedRowIds.delete(id);
    } else {
      this.state.selectedRowIds.add(id);
    }
    this.emit({ type: 'selection-change', payload: id });
  }

  getVisibleRows(scrollTop: number, viewportHeight: number): T[] {
    const { rows, rowHeight } = this.state;
    const startIndex = Math.floor(scrollTop / rowHeight);
    const endIndex = Math.min(
      rows.length,
      Math.ceil((scrollTop + viewportHeight) / rowHeight) + 1
    );
    return rows.slice(startIndex, endIndex);
  }

  getState() {
    return this.state;
  }
}
Enter fullscreen mode Exit fullscreen mode

The core never touches the DOM. It only computes which rows are visible based on scrollTop and viewportHeight. That's the foundation for virtualization.

3. Virtualization: The Key to Scale

Virtualization means rendering only the rows that fit in the viewport, plus a small buffer. For a 100,000-row grid with a 40px row height and a 600px viewport, you render about 16 rows instead of 100,000.

3.1 The Math

Given scrollTop, viewportHeight, and rowHeight:

  • startIndex = floor(scrollTop / rowHeight)
  • endIndex = ceil((scrollTop + viewportHeight) / rowHeight)
  • Add a buffer of 2–5 rows above and below to avoid blank flashes during fast scrolling.

3.2 Overscan Buffer

A buffer of 3 rows is usually enough. For smooth scrolling, we render startIndex - 3 to endIndex + 3. This hides the moment when a row enters the viewport but hasn't been rendered yet.

3.3 Fixed vs. Variable Row Heights

Fixed heights are easy—the math above works. Variable heights require measuring each row after render and storing offsets. For simplicity, we'll assume fixed heights. If you need variable heights, maintain a Map<rowId, height> and compute cumulative offsets with a Fenwick tree or binary search.

4. State Management and Change Detection

The core emits events, but it doesn't know about frameworks. Each adapter subscribes and maps events to its own reactivity system. This keeps the core pure and makes it testable with plain Node.

4.1 Event-Driven Updates

Instead of frameworks polling the core, the core pushes changes. This is critical—if the framework has to check every row on every scroll, you're back to O(n) work.

4.2 Avoiding Full Re-renders

When the user scrolls, only the visible window changes. The adapter should:

  1. Compute the new visible rows via core.getVisibleRows().
  2. Update only the DOM nodes that changed, not the entire table.

In React, this means using useMemo and React.memo on row components. In Vue, use computed and v-memo. In Angular, use ChangeDetectionStrategy.OnPush and track by index.

5. Rendering Strategies for React, Vue, and Angular

Each framework has its own idiom for rendering a list of visible rows. The key is to never iterate over all rows in the template.

5.1 React

// ReactAdapter.tsx
import React, { useMemo, useState, useEffect } from 'react';
import { GridCore } from './core/GridCore';

interface Props<T> {
  core: GridCore<T>;
  viewportHeight: number;
}

export function DataGrid<T extends { id: string }>({ core, viewportHeight }: Props<T>) {
  const [scrollTop, setScrollTop] = useState(0);

  const visibleRows = useMemo(
    () => core.getVisibleRows(scrollTop, viewportHeight),
    [core, scrollTop, viewportHeight]
  );

  useEffect(() => {
    const unsubscribe = core.subscribe((event) => {
      if (event.type === 'sort' || event.type === 'selection-change') {
        // Force re-render by updating a dummy state
        setScrollTop((prev) => prev);
      }
    });
    return unsubscribe;
  }, [core]);

  const handleScroll = (e: React.UIEvent<HTMLDivElement>) => {
    setScrollTop(e.currentTarget.scrollTop);
  };

  return (
    <div
      style={{ height: viewportHeight, overflowY: 'auto' }}
      onScroll={handleScroll}
    >
      <table>
        <thead>
          <tr>
            {core.getState().columns.map((col) => (
              <th key={col.key} style={{ width: col.width }}>
                {col.header}
              </th>
            ))}
          </tr>
        </thead>
        <tbody>
          {visibleRows.map((row) => (
            <Row
              key={row.id}
              row={row}
              columns={core.getState().columns}
              isSelected={core.getState().selectedRowIds.has(row.id)}
              onSelect={() => core.selectRow(row.id)}
            />
          ))}
        </tbody>
      </table>
    </div>
  );
}

const Row = React.memo(function Row<T extends { id: string }>({
  row,
  columns,
  isSelected,
  onSelect,
}: {
  row: T;
  columns: GridColumn<T>[];
  isSelected: boolean;
  onSelect: () => void;
}) {
  return (
    <tr
      onClick={onSelect}
      style={{ backgroundColor: isSelected ? '#e0f7fa' : 'transparent' }}
    >
      {columns.map((col) => (
        <td key={col.key}>{col.renderer ? col.renderer(row[col.key], row) : String(row[col.key])}</td>
      ))}
    </tr>
  );
});
Enter fullscreen mode Exit fullscreen mode

5.2 Vue

<!-- VueAdapter.vue -->
<template>
  <div ref="scrollContainer" class="grid" :style="{ height: viewportHeight + 'px' }">
    <table>
      <thead>
        <tr>
          <th v-for="col in columns" :key="col.key" :style="{ width: col.width + 'px' }">
            {{ col.header }}
          </th>
        </tr>
      </thead>
      <tbody>
        <tr
          v-for="row in visibleRows"
          :key="row.id"
          :class="{ selected: isSelected(row.id) }"
          @click="core.selectRow(row.id)"
        >
          <td v-for="col in columns" :key="col.key">
            {{ col.renderer ? col.renderer(row[col.key], row) : row[col.key] }}
          </td>
        </tr>
      </tbody>
    </table>
  </div>
</template>

<script setup lang="ts">
import { ref, computed, onMounted, onUnmounted } from 'vue';
import { GridCore } from './core/GridCore';

const props = defineProps<{
  core: GridCore<any>;
  viewportHeight: number;
}>();

const scrollTop = ref(0);
const scrollContainer = ref<HTMLElement | null>(null);

const columns = computed(() => props.core.getState().columns);
const visibleRows = computed(() =>
  props.core.getVisibleRows(scrollTop.value, props.viewportHeight)
);

const isSelected = (id: string) => props.core.getState().selectedRowIds.has(id);

const handleScroll = () => {
  if (scrollContainer.value) {
    scrollTop.value = scrollContainer.value.scrollTop;
  }
};

let unsubscribe: () => void;
onMounted(() => {
  unsubscribe = props.core.subscribe(() => {
    scrollTop.value = scrollTop.value; // trigger reactivity
  });
  scrollContainer.value?.addEventListener('scroll', handleScroll);
});

onUnmounted(() => {
  unsubscribe();
  scrollContainer.value?.removeEventListener('scroll', handleScroll);
});
</script>
Enter fullscreen mode Exit fullscreen mode

5.3 Angular

// angular-grid.component.ts
import { Component, Input, OnDestroy, OnInit, ChangeDetectionStrategy, ElementRef, ViewChild } from '@angular/core';
import { GridCore } from './core/GridCore';

@Component({
  selector: 'app-data-grid',
  template: `
    <div #scrollContainer class="grid" [style.height.px]="viewportHeight" (scroll)="onScroll()">
      <table>
        <thead>
          <tr>
            <th *ngFor="let col of columns" [style.width.px]="col.width">{{ col.header }}</th>
          </tr>
        </thead>
        <tbody>
          <tr
            *ngFor="let row of visibleRows"
            [class.selected]="isSelected(row.id)"
            (click)="core.selectRow(row.id)"
          >
            <td *ngFor="let col of columns">
              {{ col.renderer ? col.renderer(row[col.key], row) : row[col.key] }}
            </td>
          </tr>
        </tbody>
      </table>
    </div>
  `,
  changeDetection: ChangeDetectionStrategy.OnPush,
})
export class DataGridComponent<T extends { id: string }> implements OnInit, OnDestroy {
  @Input() core!: GridCore<T>;
  @Input() viewportHeight = 600;
  @ViewChild('scrollContainer') scrollContainer!: ElementRef<HTMLElement>;

  columns: GridColumn<T>[] = [];
  visibleRows: T[] = [];
  private unsubscribe: () => void = () => {};

  ngOnInit() {
    this.columns = this.core.getState().columns;
    this.updateVisibleRows();
    this.unsubscribe = this.core.subscribe(() => {
      this.updateVisibleRows();
    });
  }

  ngOnDestroy() {
    this.unsubscribe();
  }

  onScroll() {
    this.updateVisibleRows();
  }

  private updateVisibleRows() {
    const scrollTop = this.scrollContainer?.nativeElement.scrollTop ?? 0;
    this.visibleRows = this.core.getVisibleRows(scrollTop, this.viewportHeight);
  }

  isSelected(id: string) {
    return this.core.getState().selectedRowIds.has(id);
  }
}
Enter fullscreen mode Exit fullscreen mode

6. Implementing the Grid: A Complete Example

Let's wire it all together with a concrete dataset. We'll generate 100,000 rows and render them in React (the same core works for Vue and Angular).

// main.tsx
import React from 'react';
import { createRoot } from 'react-dom/client';
import { GridCore } from './core/GridCore';
import { DataGrid } from './ReactAdapter';

interface User {
  id: string;
  name: string;
  email: string;
  age: number;
}

const rows: User[] = Array.from({ length: 100_000 }, (_, i) => ({
  id: String(i),
  name: `User ${i}`,
  email: `user${i}@example.com`,
  age: Math.floor(Math.random() * 60) + 18,
}));

const columns = [
  { key: 'id', header: 'ID', width: 80, sortable: true },
  { key: 'name', header: 'Name', width: 200, sortable: true },
  { key: 'email', header: 'Email', width: 300 },
  { key: 'age', header: 'Age', width: 80, sortable: true },
] as const;

const core = new GridCore<User>(rows, columns, 40);

const App = () => (
  <div style={{ padding: '20px' }}>
    <h1>Scalable TypeScript Data Grid</h1>
    <button onClick={() => core.sortBy('age')}>Sort by Age</button>
    <DataGrid core={core} viewportHeight={600} />
  </div>
);

createRoot(document.getElementById('root')!).render(<App />);
Enter fullscreen mode Exit fullscreen mode

This renders 100,000 rows in memory but only ~20 in the DOM. Sorting by age triggers a single core event, which re-renders only the visible window.

7. Performance Tuning and Best Practices

7.1 Use content-visibility: auto

CSS content-visibility: auto tells the browser to skip rendering off-screen content. Combined with virtualization, it reduces layout cost further.

.grid-row {
  content-visibility: auto;
  contain-intrinsic-size: 40px; /* matches row height */
}
Enter fullscreen mode Exit fullscreen mode

7.2 Debounce Scroll Events

Scroll events fire too fast for React's setState to handle at 60fps. Use requestAnimationFrame to throttle:

let ticking = false;
const onScroll = (e: Event) => {
  if (!ticking) {
    requestAnimationFrame(() => {
      setScrollTop((e.target as HTMLElement).scrollTop);
      ticking = false;
    });
    ticking = true;
  }
};
Enter fullscreen mode Exit fullscreen mode

7.3 Memoize Cell Renderers

Expensive cell renderers (e.g., formatting dates, computing badges) should be cached. Use a WeakMap keyed by row object to avoid recomputing on every scroll.

7.4 Avoid Recreating Column Arrays

Pass columns as a stable reference. If you define columns inline in the template, React will treat them as new on every render, breaking React.memo.

7.5 Use trackBy / key Correctly

Always provide a stable key (row.id) to help the framework identify which rows changed. Without it, frameworks will re-render all visible rows on every scroll.

7.6 Profile with DevTools

Use the Performance panel to check for layout thrash. If you see purple bars, you're reading layout properties (like offsetHeight) inside a scroll handler. Cache measurements instead.

8. Conclusion

A scalable data grid isn't about choosing the right framework—it's about separating concerns. Keep the data logic in a framework-agnostic TypeScript core, use virtualization to limit DOM nodes, and let each framework's adapter handle only the visible slice. With this architecture, 100,000 rows feel as smooth as 100.

If you want to see this pattern applied to real-world projects, check out Tamiz's Insights for more engineering deep-dives.

Frequently Asked Questions

Q: Why not just use a library like AG Grid or TanStack Table?

Libraries are great for generic needs, but they often bring their own rendering model and can be hard to customize. Building a core gives you full control over performance-critical paths and lets you integrate with any framework without fighting a library's abstractions. For very large datasets with complex interactions, a custom core is often worth the effort.

Q: How do I handle variable row heights?

Maintain a Map<rowId, height> and a prefix-sum array. On scroll, binary search for the start index using the cumulative offsets. After each row renders, measure its height and update the map. This is more complex but essential for grids with text wrapping or dynamic content.

Q: Does this work with server-side data?

Yes. The core only needs a slice of data. Replace getVisibleRows with an async fetch: when the scroll position crosses a threshold, request the next chunk from the server. The core's setRows method can append the new rows, and the adapter re-renders only the visible window.

Top comments (0)