Go Back

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 — Skeletons, Sparse Arrays and Infinite Scroll

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 →

JSON

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.

TypeScript

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 database

9

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 count

17

// Called once on mount, returns immediately

18

app.get('/api/comments/count', (req, res) => {

19

res.json({ total: ALL_COMMENTS.length })

20

})

21

 

22

// Endpoint 2 — comments for a specific index range

23

// start and end are array indices, not page numbers

24

app.get('/api/comments', (req, res) => {

25

const start = parseInt(req.query.start as string) || 0

26

const end = parseInt(req.query.end as string) || 20

27

 

28

// Simulate network delay so you can see skeletons in development

29

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.

React

1

// Add this to your global CSS file

2

// @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.CSSProperties

14

 

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

React

1

interface Comment {

2

id: number

3

author: string

4

text: string

5

createdAt: string

6

}

7

 

8

export function Comment({ comment }: { comment: Comment }) {

9

const initials = comment.author

10

.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.

React

1

import { useRef, useState, useEffect, useCallback } from 'react'

2

import { useVirtualizer } from '@tanstack/react-virtual'

3

 

4

const BATCH_SIZE = 20

5

const MAX_CONCURRENT = 3

6

 

7

interface Comment {

8

id: number

9

author: string

10

text: string

11

createdAt: string

12

}

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] = comment

41

})

42

pendingUpdates.current.clear()

43

return updated

44

})

45

rafScheduled.current = false

46

}

47

 

48

const scheduleFlush = () => {

49

if (rafScheduled.current) return

50

rafScheduled.current = true

51

requestAnimationFrame(flushUpdates)

52

}

53

 

54

// --- QUEUE ---

55

const runNext = () => {

56

if (queue.current.length === 0) return

57

if (inFlight.current >= MAX_CONCURRENT) return

58

 

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) / 2

115

 

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) return

125

 

126

const first = items[0].index

127

const last = items[items.length - 1].index

128

 

129

const firstBatch = Math.floor(first / BATCH_SIZE)

130

const lastBatch = Math.floor(last / BATCH_SIZE)

131

 

132

// 🚫 CANCEL IRRELEVANT BATCHES

133

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

continue

148

}

149

 

150

const start = batch * BATCH_SIZE

151

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 success

172

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

<div

197

ref={parentRef}

198

style={{ height: '80vh', overflow: 'auto' }}

199

>

200

<div

201

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

<div

211

key={virtualItem.key}

212

ref={measureRef}

213

style={{

214

position: 'absolute',

215

top: virtualItem.start,

216

width: '100%',

217

}}

218

>

219

{comment

220

? <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:

React

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