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
- 2. Architecture: Framework-Agnostic Core
- 3. Virtualization: The Key to Scale
- 4. State Management and Change Detection
- 5. Rendering Strategies for React, Vue, and Angular
- 6. Implementing the Grid: A Complete Example
- 7. Performance Tuning and Best Practices
- 8. Conclusion
- Frequently Asked Questions
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:
- 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.
- Framework reconciliation — React's virtual DOM, Vue's reactivity, and Angular's change detection all iterate over the full data set on updates.
- 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 │ └─────────────────────────────────────────────┘ 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; } 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; } } 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:
- Compute the new visible rows via
core.getVisibleRows(). - 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> ); }); 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> 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