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

TypeScript
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

TypeScript
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
})
  • data runs 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. Leave data out 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.
  • 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, put a new item object under the same key: posts.value = posts.value.map(p => p.id === id ? { ...p, liked: true } : p).
  • estimatedHeight sizes 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.

TypeScript
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.

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

Feeding 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:

TypeScript
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

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   // px from the start edge

Pull-to-refresh

TypeScript
list.onRefresh(cb: () => void | Promise<void>): this   // spinner stays until the promise settles

The 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

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 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