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.

virtualized-list.ts TYPESCRIPT
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:

VirtualList.tsx TYPESCRIPT
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>
  )
}