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.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);
}
}
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 />);
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 */
}
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;
}
};
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)