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
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
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 acrosssetData/update/removeByKey. An index is not stable once items are inserted or removed.rendermust 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).estimatedHeightsizes 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, andappendwhile 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
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 | undefinedupdate 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
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): thisEdge callbacks — pagination
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.
list.onEndReached(600, loadNextPage) // ✓
list.onEndReached(loadNextPage) // ✗ wrong order — threshold is the first argumentPitfalls
// ✗ 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
- Containers —
UIScrollablefor short, static content - UIScreen & Router —
makeScrollable()vs child scrollers - Interactive elements —
UIButtonrows inside a list