Guides

Paging and lazy lists

A feed from a server grows by the page. A big collection in the core is read a window at a time. Neither ships a whole list across the boundary.

Two kinds of long list#

"Paging" hides two different problems, and they get different tools.

A feed from a serverA big collection in the core
Where the rows areon a server; the core fetches a page at a timealready in the core (a database, a derived view over 50,000 rows)
What the screen showsevery row loaded so fara window of it
Usean infinite query: a keyed list that grows by the pageLazy<T>: the platform pages the core's list on demand

Infinite queries#

An infinite query is a query with a cursor. The function takes the cursor and returns one page; the handle on each platform shows all the pages loaded so far as one list, keyed by the field you name.

Show the Rust code
rust
#[undra::query(key = "feed/{filter}", infinite, item_key = "id", stale = "1m")]
async fn feed(
    ctx: &Ctx,
    filter: Filter,
    #[undra(cursor)] cursor: Option<String>,
) -> Result<undra::query::Page<Post, String>, ApiError> {
    // `None` is the first page; `Page { items, next }` says where the next one starts.
    let body = fetch(ctx, filter, cursor).await?;
    Ok(Page { items: body.posts, next: body.next })
}

The cursor is not part of the cache key and not a parameter on the platform. The handle has the five signals of every query and two more, hasNextPage and fetchingNextPage, and data is the list itself, empty before the first page rather than optional.

Show the code for Swift, Kotlin and TypeScript
swift
let feed = try FeedQueryHandle(filter: .all)
List(feed.data) { post in                       // Post is Identifiable when its key is `id`
    PostRow(post)
        .onAppear { feed.loadMore(ifNeededFor: post) }   // fetches when within 5 rows of the end
}
if feed.fetchingNextPage { ProgressView() }
kotlin
val feed = FeedQueryHandle.create(Filter.All)
val rows by feed.data.collectAsState()
val state = rememberLazyListState()
LazyColumn(state = state) { items(rows, key = { it.id }) { PostRow(it) } }
state.LoadMoreWhenNearEnd(feed)                   // undra-compose
ts
const feed = await FeedQueryHandle.create(Filter.All);
const posts = useSignal(feed.data);              // keyed: a page arrives as a patch of pushes
const sentinel = useLoadMore(feed);              // ref callback for the last element
return <>{posts.map((p) => <PostRow key={p.id} post={p} />)}<div ref={sentinel} /></>;

Loading the next page appends with the same recorded push a store uses, so the change-set is the new rows and nothing else, however long the list already is. A refetch (a stale entry, invalidate, the app coming to the front, the network returning, a poll) fetches the loaded pages again in order from the first, each with the cursor the previous response returned, and sends only what changed. refetch_pages = N bounds that: a feed scrolled through fifty pages need not cost fifty requests on every refresh.

A mutation edits the whole list with CacheView::update_items, inside the optimistic transaction. persist keeps the first persist_pages pages (one by default) in the same format as any persisted query, so a cold start shows the first page at once. Not in this version: previous pages (paging backwards) and an upper bound on pages kept in memory; for a list that outgrows memory, feed a store's Lazy<T> from the query.

Lazy lists#

A store field of type Lazy<T> is a list the core owns and the platform reads a window of. The platform is told how many rows there are and which version of the list that is; it asks the core for the pages it needs.

Show the code for Rust, Swift, Kotlin and TypeScript
rust
#[undra::store]
pub struct Library {
    #[undra(key = "id")]
    books: Lazy<Book>,          // 100,000 rows; the platform never receives them whole
}

#[undra::api(store)]
impl Library {
    pub fn add(&self, title: String) { self.books.push(Book { .. }); }   // the recorded API of Signal<Vec<T>>
}
swift
let library = try Library()
ScrollView {
    LazyVStack {                                              // a lazy container: a List would build every row up front
        ForEach(0..<library.books.count, id: \.self) { i in
            if let book = library.books[i] { BookRow(book) } else { BookRow.placeholder }   // reading requests the page
        }
    }
}
kotlin
val library = Library.create()
LazyColumn { items(library.books) { index, book -> if (book != null) BookRow(book) else Placeholder() } }   // undra-compose
ts
const library = await Library.create();
const { length, getItem } = useLazyList(library.books);
// for TanStack Virtual or react-window: useVirtualizer({ count: length, ... }) and getItem(index)

Use a lazy container to show it (LazyVStack, LazyColumn, a virtualised list), never one that builds every row up front: SwiftUI's List over 0..<count reads all of them at once and the cache cannot hold that. Reading a row that is not loaded gives nil, null or undefined and asks for its page of 50 rows and one page of prefetch on each side, once. Requests made in one frame go out together. At most 24 pages are kept, least recently touched first out. A change to the list, whatever it is, is one 12-byte entry (the new length and the new version): the platform keeps showing the rows it has and asks again for the pages on screen, so a change costs the window, never the list. A reply carries the version it was read at, so a page that arrives after a newer change is thrown away and asked for again.

Cost
Observing a list of 100,000 rows20 bytes
A change of any size12 bytes, then the window
A page of 50 rows in the coremicroseconds (a few hundred nanoseconds on the reference machine)
Rows kept on the platformat most 24 pages

Lazy::over(&derived) is a read-only lazy view of a derived list (core concepts): its pages come from the derived index in O(log n + page), so a filtered and sorted view over 50,000 rows pages as cheaply as the list itself. A store with a view needs a restore hook, because the view is rebuilt from its source rather than saved. A Lazy is store state: a snapshot carries its rows, and after a restore the platform gets a new page server and reads its window again.

Rows not yet loaded have no identity, so animations across a re-page follow the index; the loaded rows have their keys. A PagingSource adapter over UndraLazyList for Jetpack Paging can follow if teams ask. On iOS 15 and 16 the Swift class is UndraLazyListObject, an ObservableObject you hold with @ObservedObject.

The playground's Library and feed use both, and the contract scenario S32 checks them on Swift, Kotlin and TypeScript. The pagination recipe shows the older shape, one query per page number.

Edit this page on GitHub