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.
The rows come one of two ways, chosen by the config: data, a function over signals, the way a
container's function children do (write the signal and the list follows), or by hand through
setData / append / prepend / update / removeByKey, the way a container's append /
setContent work. A list with data has no hand feed — the type says so. Create the list once;
never rebuild it to change its rows.
At a glance
type Post = { id: number; title: string; body: string }
const posts = signal<Post[]>([])
let page = 1
const loadPage = async () => {
const res = await fetch(`https://example.com/posts?page=${page++}`)
posts.value = [ ...posts.value, ...res.json<Post[]>() ]
}
const list = UIVirtualizedList<Post>({
data: () => posts.value,
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 })
.onEndReached(600, loadPage)
UIScreen(list).style({ bgColor: "black", pt: "safe-top" }).open()
loadPage()Configuration
UIVirtualizedList<T>(config: {
data?: () => T[] // the rows, read reactively (signals it reads); absent = fed by hand
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
})dataruns once at creation (after the chain:.onEndReached(…)and the rest are seen) and again when a signal it read changes. Rows are matched by key, so write a new array that reuses the item objects you did not change. Leavedataout to feed the list by hand (below); the two never mix on one list.keyOf— keys identify rows across writes. 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, put a new item object under the same key:posts.value = posts.value.map(p => p.id === id ? { ...p, liked: true } : p).estimatedHeightsizes a row before its first real measurement (read when the row is added). 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 rows appended while at the bottom auto-scroll to the new row.
Styling: same element styles as other containers, plus refreshControlColor (below) and
keyboardDismissMode ("interactive" | "scroll" | "none", as on UIScrollable;
"none" keeps a chat composer's keyboard up however the history is scrolled). The list has no
intrinsic height — give it flexGrow: 1 (or an explicit height) so it takes the available
space; without a size it collapses to zero and renders nothing. flexShrink: 1 is already the
default (like UIScrollable), so an explicit height that doesn't fit shrinks instead of
overflowing.
What a write does
The list tells the change from the keys: rows left out are removed, new rows are inserted where they stand in the new order — rows added at the end are appended (a chat resting at its bottom scrolls to them), rows added above what the reader is looking at move nothing (history) — and a write that does both at once (a new message and a page of history in one tick) is still two anchored inserts. Only a reorder of the rows kept (a new page of search results, a sort) rewrites the keys, with the measured heights kept. A mounted row whose item is another object re-renders; off-window rows pick up their new item when they re-enter.
messages.value = [ ...messages.value, sent ] // appended: the chat follows
messages.value = [ ...older, ...messages.value ] // history above: no scroll jump
messages.value = messages.value.filter(m => m.id !== id)Every write walks the whole array (the keys, the items that changed), so a list of thousands of rows pays a little per write; keep the array paged rather than unbounded.
list.itemCount: number // items currently held (getter)
list.getItem(key: string): T | undefinedFeeding by hand
Without data the list is fed by calls, each one the runtime's own operation — append is O(1),
no array is copied — for rows that arrive one at a time (a socket, a stream) or code that holds
no signals:
const chat = UIVirtualizedList<Message>({ keyOf: m => m.id, render: Row, estimatedHeight: 56, inverted: true })
.style({ flexGrow: 1 })
chat.setData(history) // replace wholesale — diffed by key like a `data` write
chat.append(sent) // at the end; a chat at its bottom follows
chat.prepend(...older) // above, no scroll jump
chat.update({ ...msg, read: true }) // same key, new object: a mounted row re-renders
chat.removeByKey(msg.id)A key the list already holds is dropped by append / prepend; update ignores keys it does not
hold. The two feeds are exclusive: a list made with data has none of these methods on its type,
and a call through any is refused with a logged error (the binding's next run would undo it).
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): this // px from the start edgePull-to-refresh
list.onRefresh(cb: () => void | Promise<void>): this // spinner stays until the promise settlesThe refreshControlColor style tints the spinner. The pull is a touch gesture of the phones
(Android, iOS); the desktop host and the web player have none, so the callback never fires there.
Same contract as UIScrollable's.
Edge 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 to "re-render"
screenBody.setContent([ UIVirtualizedList({...}) ]) // loses scroll, remounts everything
// ✓ keep one instance; write the signal `data` reads
// ✗ 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: a new object under the same key re-renders the row
messages.value = messages.value.map(x => x.id === m.id ? { ...x, selected: true } : x)
// ✗ mutating the array in place — same reference, the list hears nothing
messages.value.push(m)
// ✓ a new array
messages.value = [ ...messages.value, m ]
// ✗ a hand feed on a list that has `data` — not on its type; refused with a log through `any`
UIVirtualizedList({ data: () => rows.value, … }).append(m)
// ✓ one feed per list: write the signal, or leave `data` out and call append
// ✗ no size — the list has no intrinsic height, so it collapses to 0 and shows nothing
list.style({ flexGrow: 1 }) // ✓ (or an explicit height)See also
- Containers —
UIScrollablefor short, static content - UIScreen & Router — screens never scroll; the list is the screen's scrolling body
- Interactive elements —
UIButtonrows inside a list