LeCodesdocs

UIVirtualizedList

A windowed list for long or unbounded data — feeds, chats, search results. Only the rows near the viewport (plus an overscan buffer) are mounted at any moment. Use it instead of UIScrollable + items.map(Row) whenever the item count is large, grows over time, or is unknown; for a dozen static rows, a plain UIScrollable is simpler.

Data is driven imperatively — there is no diffing. Create the list once, then call setData / append / update on it; never rebuild the list to change its contents.

At a glance

TypeScript
type Post = { id: number; title: string; body: string }
let page = 1

const list = UIVirtualizedList<Post>({
  keyOf: p => String(p.id),
  estimatedHeight: p => 72 + Math.ceil(p.body.length / 38) * 20,
  render: p => UIColumn([
    UIText(p.title).style({ fontWeight: 700, color: "white", fontSize: 16 }),
    UIText(p.body).style({ color: "#bbb", fontSize: 14 }),
  ]).style({ gap: 4, px: 16, py: 14 }),
}).style({ flexGrow: 1, flexShrink: 1 })
  .onEndReached(600, async () => {
    const res = await fetch(`https://example.com/posts?page=${page++}`)
    list.append(...res.json<Post[]>())
  })

Configuration

TypeScript
UIVirtualizedList<T>(config: {
  keyOf: (item: T) => string                        // STABLE unique id (never the array index)
  render: (item: T) => UINodeChild                  // builds one row, called as it enters the window
  estimatedHeight: number | ((item: T) => number)   // px guess before the row is measured
  overscan?: number                                 // px kept mounted around the viewport; default: one viewport height
  inverted?: boolean                                // chat mode, default false
})
  • keyOf — keys identify rows across setData / update / removeByKey. An index is not stable once items are inserted or removed.
  • render must be pure over the item: a row can be unmounted and re-rendered at any time as it leaves and re-enters the window, so its output may depend only on the item passed in — not on outside mutable state, and not on a captured element you mutate later. To change a mounted row, change the item and call .update(item).
  • estimatedHeight sizes a row before its first real measurement (computed once per item, on add). It only needs to be close — better guesses just reduce scroll jitter.
  • inverted: true — chat mode: the first layout starts scrolled to the end, and append while at the bottom auto-scrolls to the new row.

Styling: same element styles as other containers. Give it flexGrow: 1, flexShrink: 1 so it takes the available space and scrolls instead of overflowing.

Data operations

TypeScript
list.setData(items: T[]): this        // replace everything (diffed by key)
list.append(...items: T[]): this      // add at the end (chat: the newest message)
list.prepend(...items: T[]): this     // add at the start WITHOUT a scroll jump (compensated)
list.update(...items: T[]): this      // re-render rows with the same keyOf(); unknown keys ignored
list.removeByKey(...keys: string[]): this

list.itemCount: number                // items currently held (getter)
list.getItem(key: string): T | undefined

update re-renders a row only if it's currently mounted; off-window rows pick up the new item naturally when they re-enter. prepend is the history-loading primitive — older messages appear above without moving what the user is reading.

Scrolling

TypeScript
list.scrollTo(offset: number, animated?: boolean): void    // px offset; animated default true
list.scrollToKey(key: string, animated?: boolean): void
list.scrollToEnd(animated?: boolean): void

list.onScroll(cb: (scrollPosition: number) => void): this

Edge callbacks — pagination

TypeScript
list.onEndReached(thresholdPx: number, callback: () => void): this     // near the BOTTOM of the content
list.onStartReached(thresholdPx: number, callback: () => void): this   // near the TOP (chat history)

The threshold (px from the edge) comes first and is required. The callback fires once when scrolling crosses into the threshold zone and is latched — it won't fire again until the user scrolls back out of the zone. Keep your own busy / end flags for in-flight and exhausted paging.

TypeScript
list.onEndReached(600, loadNextPage)      // ✓
list.onEndReached(loadNextPage)           // ✗ wrong order — threshold is the first argument

Pitfalls

TypeScript
// ✗ re-creating the list (or calling setData with mapped elements) to "re-render"
screenBody.setContent([ UIVirtualizedList({...}) ])   // loses scroll, remounts everything
// ✓ keep one instance, mutate through setData/append/update

// ✗ row reads mutable outside state — stale after an unmount/re-render cycle
render: m => UIText(selectedId === m.id ? "✓ " + m.text : m.text)
// ✓ put the state on the item and call update()
list.update({ ...m, selected: true })

// ✗ missing flexGrow/flexShrink — the list overflows the screen instead of scrolling
list.style({ flexGrow: 1, flexShrink: 1 })   // ✓

See also