Virtualized Lists
Build variable-height virtualized lists without scroll jank.
The Problem
Virtualized lists render only visible rows to save memory. But when rows have dynamic heights, the total scroll height changes as rows load. This causes scroll position jumps.
Popular libraries like TanStack Virtual and react-window have hundreds of open issues about this. The root cause: no dependency graph means recomputing ALL row heights on ANY change.
The Inval Solution
Model row heights as computed nodes that depend on viewport width and content. When width changes, only affected rows recompute. Scroll position stays stable.
import { input, node } from '@blu3ph4ntom/inval'
// External inputs
const viewportWidth = input(800)
const scrollTop = input(0)
const items = input(generateItems(1000))
// Compute row heights based on content and width
const rowHeights = node({
dependsOn: { width: viewportWidth, items },
compute: ({ width, items }) => items.map(item => {
const charsPerLine = Math.floor(width / 8)
const lines = Math.ceil(item.text.length / charsPerLine)
return lines * 20 + 16 // line height + padding
})
})
// Cumulative offsets for scroll calculation
const rowOffsets = node({
dependsOn: { heights: rowHeights },
compute: ({ heights }) => {
const offsets = [0]
for (let i = 1; i < heights.length; i++) {
offsets.push(offsets[i - 1] + heights[i - 1])
}
return offsets
}
})
// Total scrollable height
const totalHeight = node({
dependsOn: { heights: rowHeights },
compute: ({ heights }) => heights.reduce((a, b) => a + b, 0)
})
// Visible range based on scroll position
const visibleRange = node({
dependsOn: { offsets: rowOffsets, scroll: scrollTop, heights: rowHeights },
compute: ({ offsets, scroll, heights }) => {
const viewportHeight = 600
let start = 0
while (start < offsets.length - 1 && offsets[start + 1] < scroll) {
start++
}
let end = start
while (end < offsets.length && offsets[end] < scroll + viewportHeight) {
end++
}
return { start, end, count: end - start }
}
})
// Width change: only rowHeights + dependents dirty
viewportWidth.set(400)
// rowHeights.isDirty() → true
// totalHeight.isDirty() → true
// visibleRange.isDirty() → true
// Scroll change: ONLY visibleRange dirty
scrollTop.set(500)
// rowHeights.isDirty() → false
// totalHeight.isDirty() → false
// visibleRange.isDirty() → true Tip
The key insight: scrolling only invalidates visibleRange. Row heights stay cached. This is what makes Inval faster than naive approaches.
Framework Integration
Connect Inval to your rendering framework. Here's a React pattern:
import { input, node } from '@blu3ph4ntom/inval'
import { useRef, useEffect, useState } from 'react'
function VirtualList({ items, renderItem }) {
const widthInput = useRef(input(800))
const scrollInput = useRef(input(0))
const itemsInput = useRef(input(items))
const visibleRange = useRef(node({
dependsOn: {
offsets: /* ... */,
scroll: scrollInput.current,
heights: /* ... */,
},
compute: ({ offsets, scroll, heights }) => {
// compute visible range
}
}))
const [range, setRange] = useState(visibleRange.current.get())
useEffect(() => {
widthInput.current.set(containerWidth)
}, [containerWidth])
useEffect(() => {
const onScroll = () => {
scrollInput.current.set(container.scrollTop)
setRange(visibleRange.current.get())
}
container.addEventListener('scroll', onScroll)
return () => container.removeEventListener('scroll', onScroll)
}, [])
return (
<div ref={containerRef}>
{items.slice(range.start, range.end).map(renderItem)}
</div>
)
}