Varun Narayanan
April 18, 2026
Virtualizing Comments in React — Skeletons, Sparse Arrays and Infinite Scroll
A deep dive into how to virtualize a comment section in React using TanStack Virtual — with skeleton loaders, sparse arrays, batch fetching, and on-demand loading as the user scrolls.

Virtualizing Comments in React
A comment section sounds simple until you have 10,000 of them. Render them all at once and the browser freezes. Paginate them and the UX feels clunky. Infinite scroll without virtualization just pushes the problem further down — eventually you have thousands of DOM nodes and scrolling becomes janky.
The right solution is virtualization with skeleton loaders. You fetch the total count upfront, let TanStack Virtual manage which comments exist in the DOM, and fill in real data batch by batch as the user scrolls. This post walks through the full implementation from scratch.
What We Are Building
A comment feed that:
- Fetches the total comment count on mount
- Shows the correct scrollbar height immediately
- Only ever has ~20 DOM nodes regardless of total count
- Renders skeletons for comments not yet fetched
- Fetches real data in batches as the user scrolls to them
- Swaps skeletons for real comments as data arrives
No pagination buttons. No "load more". Just scroll and the content appears.
How It Works Conceptually
The key insight is separating two things that are usually coupled — knowing how many items exist and having the data for them. Mount ↓ GET /api/comments/count →
1
{ "total": 1243 }
↓
- TanStack initialized with count = 1243
- Giant div height =
1243 × 100px = 124,300px - Sparse array =
[undefined × 1243]↓ - TanStack renders visible window →
indices 0 to 19 data[0..19] = undefined→ renders skeletons ↓ Fetch fires for batch 0 (indices 0–19) Data arrives → sparse array fills at 0..19 Skeletons swap to real comments ↓- User scrolls to index 200 ↓
- TanStack mounts
indices 195–215 data[195..215] = undefined→ skeletons appear- Fetch fires for batch 10 (
indices 200–219) - Data arrives → skeletons swap
The DOM never holds more than ~20 nodes. The sparse array is the bridge between TanStack's index system and your fetched data.
The Backend
Two endpoints. One returns just the count, fast and cheap. The other returns actual comments for a range of indices.
1
import express from 'express'2
import cors from 'cors'3
4
const app = express()5
app.use(cors())6
app.use(express.json())7
8
// Fake database9
const ALL_COMMENTS = Array.from({ length: 1243 }, (_, i) => ({10
id: i,11
author: `User ${i}`,12
text: `This is comment number ${i}. It can be short or quite a bit longer depending on what the user wrote.`,13
createdAt: new Date(Date.now() - i * 60000).toISOString(),14
}))15
16
// Endpoint 1 — just the count17
// Called once on mount, returns immediately18
app.get('/api/comments/count', (req, res) => {19
res.json({ total: ALL_COMMENTS.length })20
})21
22
// Endpoint 2 — comments for a specific index range23
// start and end are array indices, not page numbers24
app.get('/api/comments', (req, res) => {25
const start = parseInt(req.query.start as string) || 026
const end = parseInt(req.query.end as string) || 2027
28
// Simulate network delay so you can see skeletons in development29
setTimeout(() => {30
res.json({31
comments: ALL_COMMENTS.slice(start, end),32
start,33
})34
}, 300)35
})36
37
app.listen(4000, () => console.log('Running on http://localhost:4000'))
The Skeleton Component
The shimmer animation is done in pure CSS. The skeleton matches the approximate shape of a real comment so the layout does not shift when real data arrives.
1
// Add this to your global CSS file2
// @keyframes shimmer {3
// 0% { background-position: 200% 0; }4
// 100% { background-position: -200% 0; }5
// }6
7
export function SkeletonComment() {8
const shimmer = {9
background: 'linear-gradient(90deg, #f0f0f0 25%, #e0e0e0 50%, #f0f0f0 75%)',10
backgroundSize: '200% 100%',11
animation: 'shimmer 1.5s infinite',12
borderRadius: '4px',13
} as React.CSSProperties14
15
return (16
<div style={{17
display: 'flex',18
gap: '12px',19
padding: '16px',20
borderBottom: '1px solid #f0f0f0',21
}}>22
{/* Avatar */}23
<div style={{ ...shimmer, width: 36, height: 36, borderRadius: '50%', flexShrink: 0 }} />24
25
{/* Text lines */}26
<div style={{ flex: 1, display: 'flex', flexDirection: 'column', gap: '8px', paddingTop: '4px' }}>27
<div style={{ ...shimmer, width: '30%', height: '12px' }} />28
<div style={{ ...shimmer, width: '100%', height: '12px' }} />29
<div style={{ ...shimmer, width: '75%', height: '12px' }} />30
</div>31
</div>32
)33
}
The Comment Component
1
interface Comment {2
id: number3
author: string4
text: string5
createdAt: string6
}7
8
export function Comment({ comment }: { comment: Comment }) {9
const initials = comment.author10
.split(' ')11
.map((w) => w[0])12
.join('')13
.toUpperCase()14
15
return (16
<div style={{17
display: 'flex',18
gap: '12px',19
padding: '16px',20
borderBottom: '1px solid #f0f0f0',21
}}>22
<div style={{23
width: 36,24
height: 36,25
borderRadius: '50%',26
background: '#1a1a1a',27
color: '#fff',28
display: 'flex',29
alignItems: 'center',30
justifyContent: 'center',31
fontSize: '13px',32
fontWeight: '600',33
flexShrink: 0,34
}}>35
{initials}36
</div>37
38
<div style={{ flex: 1 }}>39
<div style={{ fontWeight: '600', fontSize: '14px', marginBottom: '4px' }}>40
{comment.author}41
</div>42
<div style={{ fontSize: '14px', lineHeight: '1.6', color: '#333' }}>43
{comment.text}44
</div>45
<div style={{ fontSize: '12px', color: '#999', marginTop: '6px' }}>46
{new Date(comment.createdAt).toLocaleDateString()}47
</div>48
</div>49
</div>50
)51
}
The Virtual Comment List
This is where everything connects. TanStack manages the DOM window. The sparse array tracks which indices have real data. The effect watches which indices are visible and fires fetches for uncached batches.
1
import { useRef, useState, useEffect, useCallback } from 'react'2
import { useVirtualizer } from '@tanstack/react-virtual'3
4
const BATCH_SIZE = 205
const MAX_CONCURRENT = 36
7
interface Comment {8
id: number9
author: string10
text: string11
createdAt: string12
}13
14
export default function VirtualCommentList() {15
const [comments, setComments] = useState<(Comment | undefined)[]>([])16
const [total, setTotal] = useState(0)17
18
const parentRef = useRef<HTMLDivElement>(null)19
20
// --- BATCH STATE MACHINE ---21
const batchState = useRef<22
Map<number, 'idle' | 'queued' | 'fetching' | 'done'>23
>(new Map())24
25
// --- CONCURRENCY ---26
const inFlight = useRef(0)27
const queue = useRef<(() => void)[]>([])28
29
// --- ABORT CONTROLLERS ---30
const abortControllers = useRef(new Map<number, AbortController>())31
32
// --- RAF BATCHING ---33
const pendingUpdates = useRef<Map<number, Comment>>(new Map())34
const rafScheduled = useRef(false)35
36
const flushUpdates = () => {37
setComments((prev) => {38
const updated = [...prev]39
pendingUpdates.current.forEach((comment, index) => {40
updated[index] = comment41
})42
pendingUpdates.current.clear()43
return updated44
})45
rafScheduled.current = false46
}47
48
const scheduleFlush = () => {49
if (rafScheduled.current) return50
rafScheduled.current = true51
requestAnimationFrame(flushUpdates)52
}53
54
// --- QUEUE ---55
const runNext = () => {56
if (queue.current.length === 0) return57
if (inFlight.current >= MAX_CONCURRENT) return58
59
const task = queue.current.shift()60
task?.()61
}62
63
const enqueue = (batch: number, task: () => Promise<void>) => {64
const run = async () => {65
inFlight.current++66
batchState.current.set(batch, 'fetching')67
68
await task()69
70
inFlight.current--71
runNext()72
}73
74
batchState.current.set(batch, 'queued')75
76
if (inFlight.current < MAX_CONCURRENT) {77
run()78
} else {79
queue.current.push(run)80
}81
}82
83
// --- INITIAL COUNT ---84
useEffect(() => {85
fetch('http://localhost:4000/api/comments/count')86
.then((r) => r.json())87
.then(({ total }) => {88
setTotal(total)89
setComments(new Array(total).fill(undefined))90
})91
}, [])92
93
const virtualizer = useVirtualizer({94
count: total,95
getScrollElement: () => parentRef.current,96
estimateSize: () => 100,97
overscan: 3,98
})99
100
const measureRef = useCallback(101
(node: HTMLElement | null) => {102
if (node) virtualizer.measureElement(node)103
},104
[virtualizer]105
)106
107
// --- PRIORITY HELPER ---108
const getPrioritizedBatches = (firstBatch: number, lastBatch: number) => {109
const batches: number[] = []110
for (let b = firstBatch; b <= lastBatch; b++) {111
batches.push(b)112
}113
114
const center = (firstBatch + lastBatch) / 2115
116
return batches.sort(117
(a, b) => Math.abs(a - center) - Math.abs(b - center)118
)119
}120
121
// --- FETCH LOGIC ---122
useEffect(() => {123
const items = virtualizer.getVirtualItems()124
if (!items.length || total === 0) return125
126
const first = items[0].index127
const last = items[items.length - 1].index128
129
const firstBatch = Math.floor(first / BATCH_SIZE)130
const lastBatch = Math.floor(last / BATCH_SIZE)131
132
// 🚫 CANCEL IRRELEVANT BATCHES133
for (const [batch, controller] of abortControllers.current) {134
if (batch < firstBatch - 1 || batch > lastBatch + 1) {135
controller.abort()136
abortControllers.current.delete(batch)137
batchState.current.set(batch, 'idle')138
}139
}140
141
const prioritized = getPrioritizedBatches(firstBatch, lastBatch)142
143
for (const batch of prioritized) {144
const state = batchState.current.get(batch)145
146
if (state === 'done' || state === 'fetching' || state === 'queued') {147
continue148
}149
150
const start = batch * BATCH_SIZE151
const end = Math.min(start + BATCH_SIZE, total)152
153
enqueue(batch, async () => {154
const controller = new AbortController()155
abortControllers.current.set(batch, controller)156
157
try {158
const res = await fetch(159
`http://localhost:4000/api/comments?start=${start}&end=${end}`,160
{ signal: controller.signal }161
)162
163
const { comments: fetched, start: batchStart } = await res.json()164
165
fetched.forEach((comment: Comment, i: number) => {166
pendingUpdates.current.set(batchStart + i, comment)167
})168
169
scheduleFlush()170
171
// ✅ mark success172
batchState.current.set(batch, 'done')173
} catch (err: any) {174
if (err.name === 'AbortError') {175
batchState.current.set(batch, 'idle')176
} else {177
console.error('Fetch failed', err)178
batchState.current.set(batch, 'idle')179
}180
} finally {181
abortControllers.current.delete(batch)182
}183
})184
}185
}, [virtualizer.getVirtualItems(), total])186
187
// --- CLEANUP ---188
useEffect(() => {189
return () => {190
abortControllers.current.forEach((c) => c.abort())191
}192
}, [])193
194
return (195
<div style={{ maxWidth: 680, margin: '0 auto' }}>196
<div197
ref={parentRef}198
style={{ height: '80vh', overflow: 'auto' }}199
>200
<div201
style={{202
height: virtualizer.getTotalSize(),203
position: 'relative',204
}}205
>206
{virtualizer.getVirtualItems().map((virtualItem) => {207
const comment = comments[virtualItem.index]208
209
return (210
<div211
key={virtualItem.key}212
ref={measureRef}213
style={{214
position: 'absolute',215
top: virtualItem.start,216
width: '100%',217
}}218
>219
{comment220
? <Comment comment={comment} />221
: <SkeletonComment />}222
</div>223
)224
})}225
</div>226
</div>227
</div>228
)229
}
Five real divs. One giant container. Comments 0–18 and 24–1242 have zero DOM presence — not empty divs, not hidden nodes, nothing. The 124,300px height is what gives the browser a correct scrollbar. The user can scroll to the bottom and it feels like 1243 comments are there, because the scroll distance is right — but physically the DOM only holds what is visible.
The Sparse Array
The sparse array is the bridge between TanStack's index system and your fetched data:
Initial state — 1243 slots, all undefined:
[undefined, undefined, undefined, ... × 1243]
After batch 0 loads (indices 0–19):
[comment, comment, ... × 20, undefined, undefined, ... × 1223]
After user scrolls to index 200, batch 10 loads:
[comment × 20, undefined × 180, comment × 20, undefined × 1023]
Render logic — one check:
1
comment ? <Comment /> : <SkeletonComment />
When TanStack mounts a div for index 47 and comments[47] is still undefined, you render a skeleton. When the fetch for that batch completes and comments[47] gets filled, React re-renders that div with real content. No complicated state machine, no loading flags per item — just undefined or not.
The Fetch Strategy
The effect that watches visible items converts indices to batch numbers and fires fetches for any batch not already in the fetchedBatches Set:
User scrolls to index 247
↓
Visible range: indices 244 to 264
↓
firstBatch = Math.floor(244 / 20) = 12
lastBatch = Math.floor(264 / 20) = 13
↓
batch 12 in fetchedBatches? No → fetch indices 240–259, mark as in-flight
batch 13 in fetchedBatches? No → fetch indices 260–279, mark as in-flight
↓
Both fetches fire in parallel
Data arrives → sparse array fills at 240–279
React re-renders → skeletons swap to real comments
The Set prevents the same batch being fetched twice even if the user scrolls back and forth over the same region multiple times. Once a batch is in the Set it stays there for the lifetime of the component.
Variable Height Comments
Comments vary wildly in height. A one-liner and a three-paragraph comment can differ by 200px. Without correction, scrollToIndex and the total scrollbar height will be wrong.
The measureRef callback handles this. After each comment or skeleton mounts, virtualizer.measureElement(node) reads its real height via ResizeObserver and updates the internal Fenwick tree. All position offsets downstream of that item are corrected automatically. The scrollbar adjusts. No manual work needed from you beyond attaching the ref and the data-index attribute.
Over time as the user scrolls, more and more real measurements replace estimates and position accuracy improves. By the time the user has scrolled through half the list, the scroll positions for everything in that half are exact.
Skeletons Are Not Pre-Rendered
A common assumption is that all 1243 skeletons exist in the DOM from the start, with real comments swapping in as data loads. This is wrong and would defeat the purpose entirely — 1243 DOM nodes is exactly the problem we are solving.
Skeletons only appear when TanStack decides that index is within the visible window. Before the user scrolls to index 500, that index has no DOM representation at all — not a skeleton, not a hidden div, nothing. When the user scrolls there, TanStack mounts a div, comments[500] is undefined so a skeleton renders, the fetch fires, data arrives, the skeleton becomes a real comment.
The skeleton is the loading state of a freshly mounted div — not a pre-existing placeholder waiting to be filled.
When to Use This Pattern
This approach makes sense when you have a known or estimable total count and comments load quickly from your API. It gives users an immediate sense of scale — the scrollbar shows the true length of the thread from the first render.
If your total count is unknown or you are building a feed that grows indefinitely, the infinite scroll approach works better — append items to the array as the user reaches the bottom and let TanStack's count grow over time.
For most comment sections, blog posts, and forum threads where the total is known upfront, the skeleton pattern is the cleanest solution available.
— Varun Narayanan